mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(ui): add reactive status bar with notifications
This commit is contained in:
@@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue';
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { trackExists } from './downloader';
|
import { trackExists } from './downloader';
|
||||||
|
import { setInfo, setWarning } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
||||||
|
setWarning(`Skipped: ${track.title} (already exists)`);
|
||||||
return { added: false, reason: 'already_exists' };
|
return { added: false, reason: 'already_exists' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
downloadObject: track
|
downloadObject: track
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInfo(`Queued: ${track.title}`);
|
||||||
return { added: true };
|
return { added: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { trackExists } from './downloader';
|
|||||||
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
import { generateTrackPath } from './paths';
|
import { generateTrackPath } from './paths';
|
||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { setInfo, setSuccess } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
import { mkdir } from '@tauri-apps/plugin-fs';
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
@@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`);
|
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
|
// Generate m3u8 file
|
||||||
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
||||||
// Generate expected path for this track
|
// Generate expected path for this track
|
||||||
@@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
// Show success message for playlist creation
|
||||||
|
setSuccess(`Playlist created: ${playlistName}`);
|
||||||
|
|
||||||
return m3u8Path;
|
return m3u8Path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
||||||
|
import { setSuccess, setError } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
@@ -182,12 +183,29 @@ export class DeezerQueueManager {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
progress: 100
|
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) {
|
} catch (error) {
|
||||||
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
||||||
await updateQueueItem(nextItem.id, {
|
await updateQueueItem(nextItem.id, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
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
|
// Clear current job
|
||||||
|
|||||||
203
src/lib/stores/status.ts
Normal file
203
src/lib/stores/status.ts
Normal 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);
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { downloadQueue } from '$lib/stores/downloadQueue';
|
import { downloadQueue } from '$lib/stores/downloadQueue';
|
||||||
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
||||||
import { playback } from '$lib/stores/playback';
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import { status } from '$lib/stores/status';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-text">Ready</div>
|
<div class="status-text">{$status}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user