feat(mukke): clickable songs in library, player error handling & cover fallbacks

- Songs in library are now clickable to play (with full queue support)
- Active song highlighted with primary color and play/pause overlay on cover
- Player store: error state, audio error listener, auto-skip on failure
- MiniPlayer: error toast bar with dismiss button
- Library store: filter non-image paths from cover URL loading
- Cover images: onerror fallback to icon when S3 file is missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 17:05:07 +01:00
parent 26d8eb0898
commit a5940abfc2
4 changed files with 125 additions and 9 deletions

View file

@ -13,6 +13,37 @@
{#if playerStore.currentSong} {#if playerStore.currentSong}
<div class="fixed bottom-0 left-0 right-0 z-30 bg-surface border-t border-border"> <div class="fixed bottom-0 left-0 right-0 z-30 bg-surface border-t border-border">
<!-- Error toast -->
{#if playerStore.error}
<div
class="flex items-center gap-2 px-4 py-2 bg-red-500/10 border-b border-red-500/20 text-sm text-red-500"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="truncate">{playerStore.error}</span>
<button
onclick={() => playerStore.clearError()}
class="ml-auto p-0.5 hover:text-red-400 shrink-0"
aria-label="Dismiss error"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/if}
<!-- Progress bar at top --> <!-- Progress bar at top -->
<div class="h-1 w-full bg-border"> <div class="h-1 w-full bg-border">
<div class="h-full bg-primary transition-all duration-200" style="width: {progress}%"></div> <div class="h-full bg-primary transition-all duration-200" style="width: {progress}%"></div>

View file

@ -104,7 +104,9 @@ function createLibraryStore() {
}, },
async loadCoverUrls(paths: string[]) { async loadCoverUrls(paths: string[]) {
const uncached = paths.filter((p) => p && !state.coverUrls[p]); // Filter out non-image paths (e.g. .mp3 storagePaths stored as coverArtPath by mistake)
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|avif|svg)$/i;
const uncached = paths.filter((p) => p && !state.coverUrls[p] && imageExtensions.test(p));
if (uncached.length === 0) return; if (uncached.length === 0) return;
try { try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', { const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {

View file

@ -16,6 +16,7 @@ interface PlayerState {
currentIndex: number; currentIndex: number;
showFullPlayer: boolean; showFullPlayer: boolean;
showQueue: boolean; showQueue: boolean;
error: string | null;
} }
function getBackendUrl(): string { function getBackendUrl(): string {
@ -55,6 +56,7 @@ function createPlayerStore() {
currentIndex: 0, currentIndex: 0,
showFullPlayer: false, showFullPlayer: false,
showQueue: false, showQueue: false,
error: null,
}); });
let audio: HTMLAudioElement | null = null; let audio: HTMLAudioElement | null = null;
@ -70,6 +72,18 @@ function createPlayerStore() {
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
handleNext(); handleNext();
}); });
audio.addEventListener('error', () => {
const mediaError = audio!.error;
const msg =
mediaError?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? 'Audio format not supported'
: mediaError?.code === MediaError.MEDIA_ERR_NETWORK
? 'Network error while loading audio'
: 'Failed to load audio file';
console.warn(`[Mukke Player] ${msg} for song: ${state.currentSong?.title}`);
state.error = msg;
state.isPlaying = false;
});
} }
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> { async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
@ -152,6 +166,7 @@ function createPlayerStore() {
state.currentSong = song; state.currentSong = song;
state.currentTime = 0; state.currentTime = 0;
state.duration = 0; state.duration = 0;
state.error = null;
try { try {
const url = await getDownloadUrl(song.id); const url = await getDownloadUrl(song.id);
@ -160,8 +175,18 @@ function createPlayerStore() {
state.isPlaying = true; state.isPlaying = true;
updateMediaSession(song); updateMediaSession(song);
} catch (e) { } catch (e) {
console.error('Failed to play song:', e); console.warn(`[Mukke Player] Failed to play "${song.title}":`, e);
state.isPlaying = false; state.isPlaying = false;
if (!state.error) {
state.error = 'Failed to play song. The file may be missing.';
}
// Auto-skip to next song after a short delay
setTimeout(() => {
if (state.error && state.queue.length > 1) {
state.error = null;
handleNext();
}
}, 2000);
} }
} }
@ -215,6 +240,9 @@ function createPlayerStore() {
get showQueue() { get showQueue() {
return state.showQueue; return state.showQueue;
}, },
get error() {
return state.error;
},
async playSong(song: Song, queue?: Song[], startIndex?: number) { async playSong(song: Song, queue?: Song[], startIndex?: number) {
if (queue) { if (queue) {
@ -322,6 +350,10 @@ function createPlayerStore() {
state.showQueue = !state.showQueue; state.showQueue = !state.showQueue;
}, },
clearError() {
state.error = null;
},
clearQueue() { clearQueue() {
if (audio) { if (audio) {
audio.pause(); audio.pause();
@ -336,6 +368,7 @@ function createPlayerStore() {
state.currentIndex = 0; state.currentIndex = 0;
state.showFullPlayer = false; state.showFullPlayer = false;
state.showQueue = false; state.showQueue = false;
state.error = null;
}, },
removeFromQueue(index: number) { removeFromQueue(index: number) {

View file

@ -3,12 +3,14 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { libraryStore } from '$lib/stores/library.svelte'; import { libraryStore } from '$lib/stores/library.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { playerStore } from '$lib/stores/player.svelte';
import SongEditor from '$lib/components/SongEditor.svelte'; import SongEditor from '$lib/components/SongEditor.svelte';
import type { Song } from '@mukke/shared'; import type { Song } from '@mukke/shared';
const tabs = ['songs', 'albums', 'artists', 'genres'] as const; const tabs = ['songs', 'albums', 'artists', 'genres'] as const;
let editingSong = $state<Song | null>(null); let editingSong = $state<Song | null>(null);
let failedCovers = $state<Set<string>>(new Set());
function getBackendUrl(): string { function getBackendUrl(): string {
let baseUrl = 'http://localhost:3010'; let baseUrl = 'http://localhost:3010';
@ -58,6 +60,10 @@
editingSong = song; editingSong = song;
} }
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, libraryStore.songs, index);
}
async function openInEditor(songId: string, e: Event) { async function openInEditor(songId: string, e: Event) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -148,22 +154,40 @@
<span></span> <span></span>
</div> </div>
<!-- Song rows --> <!-- Song rows -->
{#each libraryStore.songs as song} {#each libraryStore.songs as song, index}
<div <div
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center" role="button"
tabindex="0"
onclick={() => handlePlaySong(song, index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlaySong(song, index);
}
}}
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer group {playerStore
.currentSong?.id === song.id
? 'bg-primary/5'
: ''}"
> >
<div <div
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0" class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0 relative"
> >
{#if song.coverArtPath && libraryStore.coverUrls[song.coverArtPath]} {#if song.coverArtPath && libraryStore.coverUrls[song.coverArtPath] && !failedCovers.has(song.id)}
<img <img
src={libraryStore.coverUrls[song.coverArtPath]} src={libraryStore.coverUrls[song.coverArtPath]}
alt="" alt=""
class="w-full h-full object-cover" class="w-full h-full object-cover"
onerror={() => {
failedCovers = new Set([...failedCovers, song.id]);
}}
/> />
{:else} {:else}
<svg <svg
class="w-5 h-5 text-foreground-secondary" class="w-5 h-5 text-foreground-secondary transition-opacity {playerStore
.currentSong?.id === song.id && playerStore.isPlaying
? 'opacity-0'
: 'group-hover:opacity-0'}"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -176,8 +200,30 @@
/> />
</svg> </svg>
{/if} {/if}
<!-- Playing indicator or play icon on hover -->
{#if playerStore.currentSong?.id === song.id && playerStore.isPlaying}
<div
class="absolute inset-0 flex items-center justify-center bg-black/40 rounded"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</div>
{:else}
<div
class="absolute inset-0 items-center justify-center bg-black/40 rounded hidden group-hover:flex"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{/if}
</div> </div>
<span class="truncate font-medium">{song.title}</span> <span
class="truncate font-medium {playerStore.currentSong?.id === song.id
? 'text-primary'
: ''}">{song.title}</span
>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span> <span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span> <span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>
<span class="text-right text-foreground-secondary text-sm"> <span class="text-right text-foreground-secondary text-sm">
@ -254,11 +300,15 @@
<div <div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden" class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
> >
{#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath]} {#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath] && !failedCovers.has(album.coverArtPath)}
<img <img
src={libraryStore.coverUrls[album.coverArtPath]} src={libraryStore.coverUrls[album.coverArtPath]}
alt={album.album} alt={album.album}
class="w-full h-full object-cover" class="w-full h-full object-cover"
onerror={() => {
if (album.coverArtPath)
failedCovers = new Set([...failedCovers, album.coverArtPath]);
}}
/> />
{:else} {:else}
<svg <svg