fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 8m29s

This commit is contained in:
doomtube 2026-01-10 19:25:42 -05:00
parent 9876641ff6
commit 9e985d05f1
11 changed files with 1011 additions and 70 deletions

View file

@ -0,0 +1,140 @@
<script>
import { onMount } from 'svelte';
/** @type {string} Audio ID to fetch waveform for */
export let audioId = '';
/** @type {string} Direct waveform path (alternative to audioId) */
export let waveformPath = '';
/** @type {boolean} Whether audio is currently playing */
export let isPlaying = false;
/** @type {number} Current playback position in seconds */
export let currentTime = 0;
/** @type {number} Total duration in seconds */
export let duration = 0;
/** @type {string} Waveform color */
export let color = '#ec4899';
/** @type {number} Opacity of the waveform (0-1) */
export let opacity = 0.15;
/** @type {boolean} Whether this is the currently playing track */
export let isCurrentTrack = false;
// Waveform data cache (module-level for sharing across instances)
const waveformCache = new Map();
/** @type {number[]} */
let peaks = [];
let loading = true;
// Calculate scroll position based on playback
$: progress = duration > 0 ? currentTime / duration : 0;
$: translateX = isCurrentTrack && isPlaying ? -(progress * 50) : 0;
async function fetchWaveform() {
if (!audioId && !waveformPath) {
loading = false;
return;
}
const cacheKey = audioId || waveformPath;
// Check cache first
if (waveformCache.has(cacheKey)) {
peaks = waveformCache.get(cacheKey);
loading = false;
return;
}
try {
let url;
if (waveformPath) {
// Direct path to waveform JSON
url = waveformPath;
} else {
// Fetch via API endpoint
url = `/api/audio/${audioId}/waveform`;
}
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
peaks = data.peaks || [];
waveformCache.set(cacheKey, peaks);
}
} catch (e) {
console.error('Failed to load waveform:', e);
} finally {
loading = false;
}
}
onMount(() => {
fetchWaveform();
});
// Refetch if audioId or waveformPath changes
$: if (audioId || waveformPath) {
fetchWaveform();
}
</script>
{#if peaks.length > 0}
<div
class="waveform-container"
style="--waveform-color: {color}; --waveform-opacity: {opacity};"
>
<svg
class="waveform-svg"
viewBox="0 0 400 100"
preserveAspectRatio="none"
style="transform: translateX({translateX}%);"
>
<!-- Mirror waveform: bars extend up and down from center -->
{#each peaks as peak, i}
{@const barWidth = 400 / peaks.length}
{@const barHeight = peak * 45}
{@const x = i * barWidth}
<!-- Top bar (from center going up) -->
<rect
x={x}
y={50 - barHeight}
width={barWidth * 0.8}
height={barHeight}
fill={color}
rx="1"
/>
<!-- Bottom bar (from center going down) -->
<rect
x={x}
y={50}
width={barWidth * 0.8}
height={barHeight}
fill={color}
rx="1"
/>
{/each}
</svg>
</div>
{/if}
<style>
.waveform-container {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: var(--waveform-opacity, 0.15);
}
.waveform-svg {
width: 200%;
height: 100%;
transition: transform 0.1s linear;
}
</style>

View file

@ -451,6 +451,7 @@
left: 0;
right: 0;
border-bottom: 1px solid #333;
overflow-x: hidden;
}
.terminal-container.undocked {
@ -487,6 +488,8 @@
gap: 0.375rem;
user-select: none;
flex-shrink: 0;
min-width: 0;
overflow: hidden;
}
.docked .terminal-header {
@ -502,6 +505,7 @@
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
min-width: 0;
}
.status {
@ -581,8 +585,10 @@
display: flex;
flex-direction: column;
overflow: hidden;
overflow-x: hidden;
background: rgb(13, 17, 23);
opacity: 0.9;
min-width: 0;
}
.datetime-container {

View file

@ -1,5 +1,7 @@
<script>
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
import { isAuthenticated } from '$lib/stores/auth';
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
/** @type {boolean} Whether the audio tab is currently active */
export let isActive = false;
@ -25,12 +27,57 @@
const volume = parseFloat(e.target.value);
audioPlaylist.setVolume(volume);
}
async function downloadAudio(e, track) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download audio');
return;
}
try {
const response = await fetch(`/api/audio/${track.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeTitle = track.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
a.download = safeTitle || 'audio';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download audio');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
</script>
<div class="audio-tab">
<!-- Player Section (terminal/pixel style) -->
<div class="player-section">
{#if $currentTrack}
{#if $currentTrack.waveformPath}
<WaveformBackground
waveformPath={$currentTrack.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={true}
opacity={0.1}
/>
{/if}
<div class="player-header">
<span class="player-label">NOW PLAYING</span>
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
@ -48,6 +95,9 @@
<span class="player-title">{$currentTrack.title}</span>
<span class="player-artist">{$currentTrack.username}</span>
</div>
{#if $isAuthenticated}
<button class="dl-btn" on:click={(e) => downloadAudio(e, $currentTrack)} title="Download"></button>
{/if}
</div>
<!-- Progress bar (pixel style) -->
@ -126,6 +176,9 @@
<span class="queue-index">{index + 1}</span>
<span class="queue-title">{track.title}</span>
<span class="queue-duration">{formatDuration(track.durationSeconds)}</span>
{#if $isAuthenticated}
<button class="queue-dl-btn" on:click|stopPropagation={(e) => downloadAudio(e, track)} title="Download"></button>
{/if}
<button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button>
</div>
{/each}
@ -154,6 +207,13 @@
padding: 0.75rem;
background: #0d1117;
border-bottom: 1px solid #21262d;
position: relative;
overflow: hidden;
}
.player-section > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
}
.player-header {
@ -238,6 +298,27 @@
filter: invert(1);
}
.dl-btn {
width: 24px;
height: 24px;
border: 1px solid #30363d;
background: #161b22;
color: #8b949e;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
}
.dl-btn:hover {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
/* Progress bar (pixel style) */
.player-progress-wrap {
display: flex;
@ -585,6 +666,29 @@
color: #f85149;
}
.queue-dl-btn {
width: 16px;
height: 16px;
border: none;
background: transparent;
color: #484f58;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s ease;
}
.queue-item:hover .queue-dl-btn {
opacity: 1;
}
.queue-dl-btn:hover {
color: #3b82f6;
}
/* Queue empty state */
.queue-empty {
display: flex;

View file

@ -2,6 +2,7 @@
import { onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { ebookReader } from '$lib/stores/ebookReader';
import { isAuthenticated } from '$lib/stores/auth';
/** @type {boolean} Whether the ebooks tab is currently active */
export let isActive = false;
@ -99,6 +100,41 @@
});
}
async function downloadEbook(e, ebook) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download ebooks');
return;
}
try {
const response = await fetch(`/api/ebooks/${ebook.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeTitle = ebook.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
a.download = `${safeTitle || 'ebook'}.epub`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download ebooks');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
function handleScroll(e) {
const target = e.target;
const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100;
@ -152,11 +188,20 @@
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
</span>
</div>
<button
class="read-btn"
on:click={() => openBook(ebook)}
title="Open in reader"
>Read</button>
<div class="ebook-actions">
<button
class="read-btn"
on:click={() => openBook(ebook)}
title="Open in reader"
>Read</button>
{#if $isAuthenticated}
<button
class="dl-btn"
on:click={(e) => downloadEbook(e, ebook)}
title="Download"
>↓</button>
{/if}
</div>
</div>
{/each}
{#if loadingMore}
@ -375,4 +420,28 @@
.read-btn:active {
background: rgba(59, 130, 246, 0.35);
}
.ebook-actions {
display: flex;
gap: 0.3rem;
flex-shrink: 0;
}
.dl-btn {
padding: 0.3rem 0.5rem;
background: rgba(107, 114, 128, 0.15);
border: 1px solid rgba(107, 114, 128, 0.3);
border-radius: 4px;
color: #6b7280;
font-size: 0.65rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.dl-btn:hover {
background: rgba(59, 130, 246, 0.25);
border-color: #3b82f6;
color: #3b82f6;
}
</style>

View file

@ -1,32 +1,59 @@
/**
* Holiday data for terminal calendar
* Includes both Indian national holidays and international holidays
* Includes Indian, American, Canadian, Australian, UK, Russian, and international holidays
*/
/**
* Fixed holidays - same date every year
* Format: { month: 0-11, day: 1-31, name: string, type: 'indian' | 'international' }
* Format: { month: 0-11, day: 1-31, name: string, type: string }
*/
export const fixedHolidays = [
// Indian National Holidays (Fixed)
{ month: 0, day: 26, name: 'Republic Day', type: 'indian' },
{ month: 7, day: 15, name: 'Independence Day', type: 'indian' },
{ month: 7, day: 15, name: 'Independence Day (India)', type: 'indian' },
{ month: 9, day: 2, name: 'Gandhi Jayanti', type: 'indian' },
// American Holidays (Fixed)
{ month: 6, day: 4, name: 'Independence Day (USA)', type: 'american' },
{ month: 10, day: 11, name: 'Veterans Day', type: 'american' },
// Canadian Holidays (Fixed)
{ month: 6, day: 1, name: 'Canada Day', type: 'canadian' },
{ month: 10, day: 11, name: 'Remembrance Day', type: 'canadian' },
// Australian Holidays (Fixed)
{ month: 0, day: 26, name: 'Australia Day', type: 'australian' },
{ month: 3, day: 25, name: 'ANZAC Day', type: 'australian' },
// UK Holidays (Fixed)
{ month: 10, day: 5, name: 'Guy Fawkes Night', type: 'uk' },
// Russian Holidays (Fixed)
{ month: 0, day: 7, name: 'Orthodox Christmas', type: 'russian' },
{ month: 1, day: 23, name: 'Defender of the Fatherland Day', type: 'russian' },
{ month: 2, day: 8, name: "International Women's Day", type: 'russian' },
{ month: 4, day: 1, name: 'Spring and Labour Day', type: 'russian' },
{ month: 4, day: 9, name: 'Victory Day', type: 'russian' },
{ month: 5, day: 12, name: 'Russia Day', type: 'russian' },
{ month: 10, day: 4, name: 'Unity Day', type: 'russian' },
// International Holidays (Fixed)
{ month: 0, day: 1, name: "New Year's Day", type: 'international' },
{ month: 1, day: 14, name: "Valentine's Day", type: 'international' },
{ month: 2, day: 17, name: "St. Patrick's Day", type: 'international' },
{ month: 9, day: 31, name: 'Halloween', type: 'international' },
{ month: 11, day: 25, name: 'Christmas', type: 'international' },
{ month: 11, day: 26, name: 'Boxing Day', type: 'international' },
{ month: 11, day: 31, name: "New Year's Eve", type: 'international' },
];
/**
* Variable holidays - different date each year (lunar calendar based)
* Variable holidays - different date each year (based on lunar calendar or day of week)
* Format: { [year]: Array<{ month: 0-11, day: 1-31, name: string, type: string }> }
*/
export const variableHolidays = {
2024: [
// Indian
{ month: 2, day: 25, name: 'Holi', type: 'indian' },
{ month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 5, day: 17, name: 'Eid ul-Adha', type: 'indian' },
@ -35,8 +62,27 @@ export const variableHolidays = {
{ month: 8, day: 7, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 12, name: 'Dussehra', type: 'indian' },
{ month: 10, day: 1, name: 'Diwali', type: 'indian' },
// American
{ month: 0, day: 15, name: 'MLK Day', type: 'american' },
{ month: 1, day: 19, name: "Presidents' Day", type: 'american' },
{ month: 4, day: 27, name: 'Memorial Day', type: 'american' },
{ month: 8, day: 2, name: 'Labor Day', type: 'american' },
{ month: 9, day: 14, name: 'Columbus Day', type: 'american' },
{ month: 10, day: 28, name: 'Thanksgiving (USA)', type: 'american' },
// Canadian
{ month: 4, day: 20, name: 'Victoria Day', type: 'canadian' },
{ month: 9, day: 14, name: 'Thanksgiving (Canada)', type: 'canadian' },
// Australian
{ month: 5, day: 10, name: "Queen's Birthday", type: 'australian' },
// UK
{ month: 2, day: 29, name: 'Good Friday', type: 'uk' },
{ month: 3, day: 1, name: 'Easter Monday', type: 'uk' },
{ month: 4, day: 6, name: 'Early May Bank Holiday', type: 'uk' },
{ month: 4, day: 27, name: 'Spring Bank Holiday', type: 'uk' },
{ month: 7, day: 26, name: 'Summer Bank Holiday', type: 'uk' },
],
2025: [
// Indian
{ month: 2, day: 14, name: 'Holi', type: 'indian' },
{ month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 5, day: 7, name: 'Eid ul-Adha', type: 'indian' },
@ -45,8 +91,27 @@ export const variableHolidays = {
{ month: 7, day: 27, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 2, name: 'Dussehra', type: 'indian' },
{ month: 9, day: 20, name: 'Diwali', type: 'indian' },
// American
{ month: 0, day: 20, name: 'MLK Day', type: 'american' },
{ month: 1, day: 17, name: "Presidents' Day", type: 'american' },
{ month: 4, day: 26, name: 'Memorial Day', type: 'american' },
{ month: 8, day: 1, name: 'Labor Day', type: 'american' },
{ month: 9, day: 13, name: 'Columbus Day', type: 'american' },
{ month: 10, day: 27, name: 'Thanksgiving (USA)', type: 'american' },
// Canadian
{ month: 4, day: 19, name: 'Victoria Day', type: 'canadian' },
{ month: 9, day: 13, name: 'Thanksgiving (Canada)', type: 'canadian' },
// Australian
{ month: 5, day: 9, name: "Queen's Birthday", type: 'australian' },
// UK
{ month: 3, day: 18, name: 'Good Friday', type: 'uk' },
{ month: 3, day: 21, name: 'Easter Monday', type: 'uk' },
{ month: 4, day: 5, name: 'Early May Bank Holiday', type: 'uk' },
{ month: 4, day: 26, name: 'Spring Bank Holiday', type: 'uk' },
{ month: 7, day: 25, name: 'Summer Bank Holiday', type: 'uk' },
],
2026: [
// Indian
{ month: 2, day: 4, name: 'Holi', type: 'indian' },
{ month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 4, day: 27, name: 'Eid ul-Adha', type: 'indian' },
@ -55,8 +120,27 @@ export const variableHolidays = {
{ month: 8, day: 17, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 8, day: 20, name: 'Dussehra', type: 'indian' },
{ month: 10, day: 8, name: 'Diwali', type: 'indian' },
// American
{ month: 0, day: 19, name: 'MLK Day', type: 'american' },
{ month: 1, day: 16, name: "Presidents' Day", type: 'american' },
{ month: 4, day: 25, name: 'Memorial Day', type: 'american' },
{ month: 8, day: 7, name: 'Labor Day', type: 'american' },
{ month: 9, day: 12, name: 'Columbus Day', type: 'american' },
{ month: 10, day: 26, name: 'Thanksgiving (USA)', type: 'american' },
// Canadian
{ month: 4, day: 18, name: 'Victoria Day', type: 'canadian' },
{ month: 9, day: 12, name: 'Thanksgiving (Canada)', type: 'canadian' },
// Australian
{ month: 5, day: 8, name: "Queen's Birthday", type: 'australian' },
// UK
{ month: 3, day: 3, name: 'Good Friday', type: 'uk' },
{ month: 3, day: 6, name: 'Easter Monday', type: 'uk' },
{ month: 4, day: 4, name: 'Early May Bank Holiday', type: 'uk' },
{ month: 4, day: 25, name: 'Spring Bank Holiday', type: 'uk' },
{ month: 7, day: 31, name: 'Summer Bank Holiday', type: 'uk' },
],
2027: [
// Indian
{ month: 2, day: 22, name: 'Holi', type: 'indian' },
{ month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 4, day: 17, name: 'Eid ul-Adha', type: 'indian' },
@ -65,6 +149,24 @@ export const variableHolidays = {
{ month: 8, day: 6, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 9, name: 'Dussehra', type: 'indian' },
{ month: 9, day: 28, name: 'Diwali', type: 'indian' },
// American
{ month: 0, day: 18, name: 'MLK Day', type: 'american' },
{ month: 1, day: 15, name: "Presidents' Day", type: 'american' },
{ month: 4, day: 31, name: 'Memorial Day', type: 'american' },
{ month: 8, day: 6, name: 'Labor Day', type: 'american' },
{ month: 9, day: 11, name: 'Columbus Day', type: 'american' },
{ month: 10, day: 25, name: 'Thanksgiving (USA)', type: 'american' },
// Canadian
{ month: 4, day: 24, name: 'Victoria Day', type: 'canadian' },
{ month: 9, day: 11, name: 'Thanksgiving (Canada)', type: 'canadian' },
// Australian
{ month: 5, day: 14, name: "Queen's Birthday", type: 'australian' },
// UK
{ month: 2, day: 26, name: 'Good Friday', type: 'uk' },
{ month: 2, day: 29, name: 'Easter Monday', type: 'uk' },
{ month: 4, day: 3, name: 'Early May Bank Holiday', type: 'uk' },
{ month: 4, day: 31, name: 'Spring Bank Holiday', type: 'uk' },
{ month: 7, day: 30, name: 'Summer Bank Holiday', type: 'uk' },
],
};

View file

@ -2,7 +2,9 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { audioPlaylist } from '$lib/stores/audioPlaylist';
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
import { auth, isAuthenticated } from '$lib/stores/auth';
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
let audioFiles = [];
let loading = true;
@ -55,6 +57,20 @@
return $audioPlaylist.queue.some(t => t.id === audioId);
}
function isCurrentlyPlaying(audioId) {
return $currentTrack?.id === audioId && $audioPlaylist.isPlaying;
}
function handlePlayClick(audio, realmName) {
if ($currentTrack?.id === audio.id) {
// Toggle play/pause if this is the current track
audioPlaylist.togglePlay();
} else {
// Play this track
playNow(audio, realmName);
}
}
function togglePlaylist(audio, realmName) {
if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id);
@ -65,6 +81,7 @@
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds,
realmName: realmName
});
@ -78,11 +95,47 @@
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds,
realmName: realmName
});
}
async function downloadAudio(e, audio) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download audio');
return;
}
try {
const response = await fetch(`/api/audio/${audio.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
a.download = safeTitle || 'audio';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download audio');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
async function loadAudio(append = false) {
if (!browser) return;
@ -111,6 +164,7 @@
}
onMount(() => {
auth.init();
loadAudio();
});
</script>
@ -204,6 +258,13 @@
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.audio-item > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
}
.audio-item:hover {
@ -316,12 +377,23 @@
color: white;
}
.action-btn.play.playing {
background: #ec4899;
color: white;
}
.action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.action-btn.download:hover {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.no-audio {
text-align: center;
padding: 4rem 0;
@ -387,6 +459,15 @@
<div class="audio-list">
{#each group.audio.slice(0, 5) as audio, index}
<div class="audio-item">
{#if audio.waveformPath}
<WaveformBackground
waveformPath={audio.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={$currentTrack?.id === audio.id}
/>
{/if}
<span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail">
{#if audio.thumbnailPath}
@ -411,10 +492,11 @@
<div class="audio-actions">
<button
class="action-btn play"
on:click={() => playNow(audio, group.realmName)}
title="Play now"
class:playing={isCurrentlyPlaying(audio.id)}
on:click={() => handlePlayClick(audio, group.realmName)}
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
>
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
</button>
<button
class="action-btn"
@ -424,6 +506,15 @@
>
{isInPlaylist(audio.id) ? '✓' : '+'}
</button>
{#if $isAuthenticated}
<button
class="action-btn download"
on:click={(e) => downloadAudio(e, audio)}
title="Download"
>
</button>
{/if}
</div>
</div>
{/each}

View file

@ -2,7 +2,9 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { audioPlaylist } from '$lib/stores/audioPlaylist';
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
import { auth, isAuthenticated } from '$lib/stores/auth';
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
let realm = null;
let audioFiles = [];
@ -41,6 +43,20 @@
return $audioPlaylist.queue.some(t => t.id === audioId);
}
function isCurrentlyPlaying(audioId) {
return $currentTrack?.id === audioId && $audioPlaylist.isPlaying;
}
function handlePlayClick(audio) {
if ($currentTrack?.id === audio.id) {
// Toggle play/pause if this is the current track
audioPlaylist.togglePlay();
} else {
// Play this track
playNow(audio);
}
}
function togglePlaylist(audio) {
if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id);
@ -51,6 +67,7 @@
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
@ -64,6 +81,7 @@
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
@ -76,6 +94,7 @@
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
@ -89,6 +108,41 @@
});
}
async function downloadAudio(e, audio) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download audio');
return;
}
try {
const response = await fetch(`/api/audio/${audio.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
a.download = safeTitle || 'audio';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download audio');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
async function loadRealmAudio() {
if (!browser || !realmName) return;
@ -114,6 +168,7 @@
let prevRealmName = null;
onMount(() => {
auth.init();
prevRealmName = realmName;
loadRealmAudio();
});
@ -240,6 +295,13 @@
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.audio-item > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
}
.audio-item:hover {
@ -350,12 +412,23 @@
color: white;
}
.action-btn.play.playing {
background: #ec4899;
color: white;
}
.action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.action-btn.download:hover {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.no-audio {
text-align: center;
padding: 4rem 0;
@ -444,6 +517,15 @@
<div class="audio-list">
{#each audioFiles as audio, index}
<div class="audio-item">
{#if audio.waveformPath}
<WaveformBackground
waveformPath={audio.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={$currentTrack?.id === audio.id}
/>
{/if}
<span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail">
{#if audio.thumbnailPath}
@ -466,10 +548,11 @@
<div class="audio-actions">
<button
class="action-btn play"
on:click={() => playNow(audio)}
title="Play now"
class:playing={isCurrentlyPlaying(audio.id)}
on:click={() => handlePlayClick(audio)}
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
>
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
</button>
<button
class="action-btn"
@ -479,6 +562,15 @@
>
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
</button>
{#if $isAuthenticated}
<button
class="action-btn download"
on:click={(e) => downloadAudio(e, audio)}
title="Download"
>
</button>
{/if}
</div>
</div>
{/each}