feat(ui): add reactive status bar with notifications

This commit is contained in:
2025-10-04 23:36:09 -04:00
parent c30b205d9c
commit 38db835973
5 changed files with 239 additions and 1 deletions

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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

203
src/lib/stores/status.ts Normal file
View File

@@ -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<StatusLevel, number> = {
idle: 0,
info: 3000,
success: 4000,
warning: 6000,
error: 8000
};
// Priority order (higher = more important)
const PRIORITY: Record<StatusLevel, number> = {
error: 4,
warning: 3,
success: 2,
info: 1,
idle: 0
};
// Internal store
const statusState = writable<StatusState>({
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);

View File

@@ -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 @@
</div>
</div>
<div class="status-text">Ready</div>
<div class="status-text">{$status}</div>
</div>
<style>