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}
<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 -->
<div class="h-1 w-full bg-border">
<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[]) {
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;
try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {

View file

@ -16,6 +16,7 @@ interface PlayerState {
currentIndex: number;
showFullPlayer: boolean;
showQueue: boolean;
error: string | null;
}
function getBackendUrl(): string {
@ -55,6 +56,7 @@ function createPlayerStore() {
currentIndex: 0,
showFullPlayer: false,
showQueue: false,
error: null,
});
let audio: HTMLAudioElement | null = null;
@ -70,6 +72,18 @@ function createPlayerStore() {
audio.addEventListener('ended', () => {
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> {
@ -152,6 +166,7 @@ function createPlayerStore() {
state.currentSong = song;
state.currentTime = 0;
state.duration = 0;
state.error = null;
try {
const url = await getDownloadUrl(song.id);
@ -160,8 +175,18 @@ function createPlayerStore() {
state.isPlaying = true;
updateMediaSession(song);
} catch (e) {
console.error('Failed to play song:', e);
console.warn(`[Mukke Player] Failed to play "${song.title}":`, e);
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() {
return state.showQueue;
},
get error() {
return state.error;
},
async playSong(song: Song, queue?: Song[], startIndex?: number) {
if (queue) {
@ -322,6 +350,10 @@ function createPlayerStore() {
state.showQueue = !state.showQueue;
},
clearError() {
state.error = null;
},
clearQueue() {
if (audio) {
audio.pause();
@ -336,6 +368,7 @@ function createPlayerStore() {
state.currentIndex = 0;
state.showFullPlayer = false;
state.showQueue = false;
state.error = null;
},
removeFromQueue(index: number) {

View file

@ -3,12 +3,14 @@
import { goto } from '$app/navigation';
import { libraryStore } from '$lib/stores/library.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { playerStore } from '$lib/stores/player.svelte';
import SongEditor from '$lib/components/SongEditor.svelte';
import type { Song } from '@mukke/shared';
const tabs = ['songs', 'albums', 'artists', 'genres'] as const;
let editingSong = $state<Song | null>(null);
let failedCovers = $state<Set<string>>(new Set());
function getBackendUrl(): string {
let baseUrl = 'http://localhost:3010';
@ -58,6 +60,10 @@
editingSong = song;
}
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, libraryStore.songs, index);
}
async function openInEditor(songId: string, e: Event) {
e.preventDefault();
e.stopPropagation();
@ -148,22 +154,40 @@
<span></span>
</div>
<!-- Song rows -->
{#each libraryStore.songs as song}
{#each libraryStore.songs as song, index}
<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
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
src={libraryStore.coverUrls[song.coverArtPath]}
alt=""
class="w-full h-full object-cover"
onerror={() => {
failedCovers = new Set([...failedCovers, song.id]);
}}
/>
{:else}
<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"
stroke="currentColor"
viewBox="0 0 24 24"
@ -176,8 +200,30 @@
/>
</svg>
{/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>
<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.album ?? 'Unknown'}</span>
<span class="text-right text-foreground-secondary text-sm">
@ -254,11 +300,15 @@
<div
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
src={libraryStore.coverUrls[album.coverArtPath]}
alt={album.album}
class="w-full h-full object-cover"
onerror={() => {
if (album.coverArtPath)
failedCovers = new Set([...failedCovers, album.coverArtPath]);
}}
/>
{:else}
<svg