mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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}
|
{#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>
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue