mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
26d8eb0898
commit
a5940abfc2
4 changed files with 125 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue