This commit is contained in:
parent
9876641ff6
commit
9e985d05f1
11 changed files with 1011 additions and 70 deletions
140
frontend/src/lib/components/WaveformBackground.svelte
Normal file
140
frontend/src/lib/components/WaveformBackground.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue