diff --git a/src/lib/services/deezer/addToQueue.ts b/src/lib/services/deezer/addToQueue.ts index 61ddd2c..d2e012d 100644 --- a/src/lib/services/deezer/addToQueue.ts +++ b/src/lib/services/deezer/addToQueue.ts @@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue'; import { settings } from '$lib/stores/settings'; import { deezerAuth } from '$lib/stores/deezer'; import { trackExists } from './downloader'; +import { setInfo, setWarning } from '$lib/stores/status'; import { get } from 'svelte/store'; /** @@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b if (exists) { console.log(`[AddToQueue] Skipping "${track.title}" - already exists`); + setWarning(`Skipped: ${track.title} (already exists)`); return { added: false, reason: 'already_exists' }; } } @@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b downloadObject: track }); + setInfo(`Queued: ${track.title}`); return { added: true }; } diff --git a/src/lib/services/deezer/playlistDownloader.ts b/src/lib/services/deezer/playlistDownloader.ts index f0f9a3f..cccafee 100644 --- a/src/lib/services/deezer/playlistDownloader.ts +++ b/src/lib/services/deezer/playlistDownloader.ts @@ -7,6 +7,7 @@ import { trackExists } from './downloader'; import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8'; import { generateTrackPath } from './paths'; import { settings } from '$lib/stores/settings'; +import { setInfo, setSuccess } from '$lib/stores/status'; import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; import { mkdir } from '@tauri-apps/plugin-fs'; @@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist( console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`); + // Show queue status message + if (addedCount > 0) { + if (skippedCount > 0) { + setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''} (${skippedCount} skipped)`); + } else { + setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''}`); + } + } + // Generate m3u8 file const m3u8Tracks: M3U8Track[] = tracks.map(track => { // Generate expected path for this track @@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist( console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`); + // Show success message for playlist creation + setSuccess(`Playlist created: ${playlistName}`); + return m3u8Path; } diff --git a/src/lib/services/deezer/queueManager.ts b/src/lib/services/deezer/queueManager.ts index 2a48575..4fc26c8 100644 --- a/src/lib/services/deezer/queueManager.ts +++ b/src/lib/services/deezer/queueManager.ts @@ -15,6 +15,7 @@ import { import { settings } from '$lib/stores/settings'; import { deezerAuth } from '$lib/stores/deezer'; import { syncTrackPaths } from '$lib/library/incrementalSync'; +import { setSuccess, setError } from '$lib/stores/status'; import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; @@ -182,12 +183,29 @@ export class DeezerQueueManager { status: 'completed', progress: 100 }); + + // Show success message + if (nextItem.type === 'track') { + setSuccess(`Downloaded: ${nextItem.title}`); + } else { + const completed = nextItem.completedTracks; + const failed = nextItem.failedTracks; + if (failed > 0) { + setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''} (${failed} failed)`); + } else { + setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''}`); + } + } } catch (error) { console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error); await updateQueueItem(nextItem.id, { status: 'failed', error: error instanceof Error ? error.message : 'Unknown error' }); + + // Show error message + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + setError(`Download failed: ${errorMsg}`); } // Clear current job diff --git a/src/lib/stores/status.ts b/src/lib/stores/status.ts new file mode 100644 index 0000000..692da1d --- /dev/null +++ b/src/lib/stores/status.ts @@ -0,0 +1,203 @@ +/** + * Status notification service + * Provides a reactive status bar that shows download progress, notifications, and app state + */ + +import { writable, derived, get } from 'svelte/store'; +import { downloadQueue } from './downloadQueue'; + +export type StatusLevel = 'idle' | 'info' | 'success' | 'warning' | 'error'; + +export interface StatusMessage { + id: number; + message: string; + level: StatusLevel; + timestamp: number; + expiresAt: number; +} + +interface StatusState { + messages: StatusMessage[]; + nextId: number; +} + +// Default expiration times (in ms) +const EXPIRATION_TIMES: Record = { + idle: 0, + info: 3000, + success: 4000, + warning: 6000, + error: 8000 +}; + +// Priority order (higher = more important) +const PRIORITY: Record = { + error: 4, + warning: 3, + success: 2, + info: 1, + idle: 0 +}; + +// Internal store +const statusState = writable({ + messages: [], + nextId: 0 +}); + +/** + * Push a new status message + */ +export function pushStatus( + message: string, + level: StatusLevel = 'info', + duration?: number +): number { + const state = get(statusState); + const id = state.nextId; + const timestamp = Date.now(); + const expiresAt = timestamp + (duration ?? EXPIRATION_TIMES[level]); + + const newMessage: StatusMessage = { + id, + message, + level, + timestamp, + expiresAt + }; + + statusState.update(s => ({ + messages: [...s.messages, newMessage], + nextId: s.nextId + 1 + })); + + // Auto-remove after expiration + if (duration !== 0 && (duration ?? EXPIRATION_TIMES[level]) > 0) { + setTimeout(() => { + removeStatus(id); + }, duration ?? EXPIRATION_TIMES[level]); + } + + return id; +} + +/** + * Remove a status message by ID + */ +export function removeStatus(id: number): void { + statusState.update(s => ({ + ...s, + messages: s.messages.filter(m => m.id !== id) + })); +} + +/** + * Clear all messages of a specific level (or all if no level specified) + */ +export function clearStatus(level?: StatusLevel): void { + statusState.update(s => ({ + ...s, + messages: level ? s.messages.filter(m => m.level !== level) : [] + })); +} + +/** + * Convenience methods + */ +export function setInfo(message: string, duration?: number): number { + return pushStatus(message, 'info', duration); +} + +export function setSuccess(message: string, duration?: number): number { + return pushStatus(message, 'success', duration); +} + +export function setWarning(message: string, duration?: number): number { + return pushStatus(message, 'warning', duration); +} + +export function setError(message: string, duration?: number): number { + return pushStatus(message, 'error', duration); +} + +/** + * Derive download status from queue state + */ +function deriveDownloadStatus(queueState: any): string | null { + const activeDownloads = queueState.queueOrder.filter((id: string) => { + const item = queueState.queue[id]; + return item && (item.status === 'queued' || item.status === 'downloading'); + }); + + const currentItem = queueState.currentJob + ? queueState.queue[queueState.currentJob] + : null; + + if (currentItem && currentItem.status === 'downloading') { + if (currentItem.type === 'track') { + return `Downloading: ${currentItem.title}`; + } else { + const total = currentItem.totalTracks; + const completed = currentItem.completedTracks; + const failed = currentItem.failedTracks; + const remaining = total - completed - failed; + + if (remaining > 0) { + return `Downloading ${currentItem.title} (${completed}/${total} tracks)`; + } else { + return `Finishing ${currentItem.title}...`; + } + } + } + + if (activeDownloads.length > 0) { + return `${activeDownloads.length} download${activeDownloads.length !== 1 ? 's' : ''} queued`; + } + + return null; +} + +/** + * Main status store - shows the highest priority active message or derived download status + */ +export const status = derived( + [statusState, downloadQueue], + ([$statusState, $downloadQueue]) => { + const now = Date.now(); + + // Filter out expired messages + const activeMessages = $statusState.messages.filter(m => m.expiresAt > now); + + // Get highest priority message + if (activeMessages.length > 0) { + const sorted = activeMessages.sort((a, b) => { + // Sort by priority first + const priorityDiff = PRIORITY[b.level] - PRIORITY[a.level]; + if (priorityDiff !== 0) return priorityDiff; + + // Then by timestamp (newer first) + return b.timestamp - a.timestamp; + }); + + return sorted[0].message; + } + + // No explicit messages - check download queue + const downloadStatus = deriveDownloadStatus($downloadQueue); + if (downloadStatus) { + return downloadStatus; + } + + // Default to idle + return 'Idle'; + } +); + +// Clean up expired messages periodically +setInterval(() => { + const now = Date.now(); + statusState.update(s => ({ + ...s, + messages: s.messages.filter(m => m.expiresAt > now) + })); +}, 1000); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b49d34a..5c6eeb3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { downloadQueue } from '$lib/stores/downloadQueue'; import { deezerQueueManager } from '$lib/services/deezer/queueManager'; import { playback } from '$lib/stores/playback'; + import { status } from '$lib/stores/status'; let { children } = $props(); @@ -185,7 +186,7 @@ -
Ready
+
{$status}