From dfdb236b2e1ddc1257e446424906bc263ac98025 Mon Sep 17 00:00:00 2001 From: Markury Date: Wed, 1 Oct 2025 15:30:10 -0400 Subject: [PATCH] feat(layout): playlist cover art, album UI --- src/lib/library/album.ts | 136 +++++++++ src/lib/library/playlist.ts | 36 ++- .../albums/[artist]/[album]/+page.svelte | 263 ++++++++++++++++++ src/routes/library/+page.svelte | 8 +- src/routes/playlists/[name]/+page.svelte | 235 ++++++++++++---- 5 files changed, 614 insertions(+), 64 deletions(-) create mode 100644 src/lib/library/album.ts create mode 100644 src/routes/albums/[artist]/[album]/+page.svelte diff --git a/src/lib/library/album.ts b/src/lib/library/album.ts new file mode 100644 index 0000000..9472b5d --- /dev/null +++ b/src/lib/library/album.ts @@ -0,0 +1,136 @@ +import { readDir, readFile } from '@tauri-apps/plugin-fs'; +import { parseBuffer } from 'music-metadata'; +import type { Track, AudioFormat, TrackMetadata } from '$lib/types/track'; + +/** + * Get audio format from file extension + */ +function getAudioFormat(filename: string): AudioFormat { + const ext = filename.toLowerCase().split('.').pop(); + switch (ext) { + case 'flac': + return 'flac'; + case 'mp3': + return 'mp3'; + case 'opus': + return 'opus'; + case 'ogg': + return 'ogg'; + case 'm4a': + return 'm4a'; + case 'wav': + return 'wav'; + default: + return 'unknown'; + } +} + +/** + * Read metadata from audio file + */ +async function readAudioMetadata(filePath: string, format: AudioFormat): Promise { + try { + const fileData = await readFile(filePath); + + const mimeMap: Record = { + 'flac': 'audio/flac', + 'mp3': 'audio/mpeg', + 'opus': 'audio/opus', + 'ogg': 'audio/ogg', + 'm4a': 'audio/mp4', + 'wav': 'audio/wav', + 'unknown': 'audio/mpeg' + }; + + const metadata = await parseBuffer(fileData, mimeMap[format], { duration: true }); + + return { + title: metadata.common.title, + artist: metadata.common.artist, + album: metadata.common.album, + albumArtist: metadata.common.albumartist, + year: metadata.common.year, + trackNumber: metadata.common.track?.no ?? undefined, + genre: metadata.common.genre?.[0], + duration: metadata.format.duration, + bitrate: metadata.format.bitrate ? Math.round(metadata.format.bitrate / 1000) : undefined, + sampleRate: metadata.format.sampleRate + }; + } catch (error) { + console.error('Error reading audio metadata:', error); + return {}; + } +} + +/** + * Find cover art image in album directory + */ +export async function findAlbumArt(albumPath: string): Promise { + try { + const entries = await readDir(albumPath); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']; + + for (const entry of entries) { + if (!entry.isDirectory) { + const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext)); + if (hasImageExt) { + return `${albumPath}/${entry.name}`; + } + } + } + return undefined; + } catch (error) { + console.error('Error finding album art:', error); + return undefined; + } +} + +/** + * Load album tracks with metadata + */ +export async function loadAlbumTracks(albumPath: string): Promise { + try { + const entries = await readDir(albumPath); + const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3']; + + // Filter audio files + const audioFiles = entries.filter( + entry => !entry.isDirectory && audioExtensions.some(ext => entry.name.endsWith(ext)) + ); + + // Load tracks with metadata in parallel + const tracks = await Promise.all( + audioFiles.map(async (entry) => { + const filePath = `${albumPath}/${entry.name}`; + const format = getAudioFormat(entry.name); + const metadata = await readAudioMetadata(filePath, format); + + // Fallback to filename if no title metadata + if (!metadata.title) { + const nameWithoutExt = entry.name.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, ''); + metadata.title = nameWithoutExt; + } + + return { + path: filePath, + filename: entry.name, + format, + metadata + }; + }) + ); + + // Sort by track number if available, otherwise by filename + tracks.sort((a, b) => { + if (a.metadata.trackNumber && b.metadata.trackNumber) { + return a.metadata.trackNumber - b.metadata.trackNumber; + } + return a.filename.localeCompare(b.filename); + }); + + return tracks; + } catch (error) { + console.error('Error loading album tracks:', error); + return []; + } +} diff --git a/src/lib/library/playlist.ts b/src/lib/library/playlist.ts index c7c109e..d2292e8 100644 --- a/src/lib/library/playlist.ts +++ b/src/lib/library/playlist.ts @@ -1,4 +1,4 @@ -import { readTextFile, readFile, exists } from '@tauri-apps/plugin-fs'; +import { readTextFile, readFile, exists, readDir } from '@tauri-apps/plugin-fs'; import { parseBuffer } from 'music-metadata'; import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track'; @@ -148,6 +148,40 @@ async function readAudioMetadata(filePath: string, format: AudioFormat): Promise } } +/** + * Find cover art for playlist by looking for image file with same name + * E.g., "My Playlist.m3u" -> look for "My Playlist.jpg/png/etc." + */ +export async function findPlaylistArt(playlistPath: string): Promise { + try { + const pathParts = playlistPath.split('/'); + const playlistFilename = pathParts[pathParts.length - 1]; + const playlistDir = pathParts.slice(0, -1).join('/'); + + // Remove playlist extension to get base name + const baseName = playlistFilename.replace(/\.(m3u8?|M3U8?)$/, ''); + + // Look for image files with same base name + const entries = await readDir(playlistDir); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']; + + for (const entry of entries) { + if (!entry.isDirectory) { + for (const ext of imageExtensions) { + if (entry.name === baseName + ext) { + return `${playlistDir}/${entry.name}`; + } + } + } + } + + return undefined; + } catch (error) { + console.error('Error finding playlist art:', error); + return undefined; + } +} + /** * Load playlist with track information */ diff --git a/src/routes/albums/[artist]/[album]/+page.svelte b/src/routes/albums/[artist]/[album]/+page.svelte new file mode 100644 index 0000000..89c70f1 --- /dev/null +++ b/src/routes/albums/[artist]/[album]/+page.svelte @@ -0,0 +1,263 @@ + + +
+ {#if loading} +

Loading album...

+ {:else if error} +

{error}

+ {:else} + +
+ {#if coverArtPath} + {albumName} cover + {:else} +
+ {/if} +
+

{albumName}

+

{artistName}

+

+ {getAlbumYear()} • {tracks.length} track{tracks.length !== 1 ? 's' : ''} +

+
+
+ +
+ + +
  • + +
  • +
    + + +
    +
    +
    + + + + + + + + + + + {#each tracks as track, i} + handleTrackClick(i)} + > + + + + + + {/each} + +
    #TitleDurationFormat
    + {track.metadata.trackNumber ?? i + 1} + {track.metadata.title || track.filename} + {#if track.metadata.duration} + {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')} + {:else} + — + {/if} + {track.format.toUpperCase()}
    +
    +
    +
    +
    + {/if} +
    + + diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index d9ac436..8b7463f 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -1,5 +1,6 @@ -
    -

    {playlistName}

    - +
    {#if loading} -

    Loading playlist...

    +

    Loading playlist...

    {:else if error} -

    {error}

    +

    {error}

    {:else if playlistData && playlistData.tracks.length === 0} -

    No tracks in this playlist.

    +

    No tracks in this playlist.

    {:else if playlistData} -
    -

    {playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}

    + +
    + {#if coverArtPath} + {playlistName} cover + {:else} +
    + {/if} +
    +

    {playlistName}

    +

    + {playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''} +

    +
    +
    - - - - - - - - - - - - - {#each playlistData.tracks as track, index} - - - - - - - - - {/each} - -
    #TitleArtistAlbumDurationFormat
    {index + 1}{track.metadata.title || track.filename}{track.metadata.artist || '—'}{track.metadata.album || '—'} - {#if track.metadata.duration} - {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')} - {:else} - — - {/if} - {track.format.toUpperCase()}
    +
    + + +
  • + +
  • +
    + + +
    +
    +
    + + + + + + + + + + + + + {#each playlistData.tracks as track, index} + handleTrackClick(index)} + > + + + + + + + + {/each} + +
    #TitleArtistAlbumDurationFormat
    {index + 1}{track.metadata.title || track.filename}{track.metadata.artist || '—'}{track.metadata.album || '—'} + {#if track.metadata.duration} + {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')} + {:else} + — + {/if} + {track.format.toUpperCase()}
    +
    +
    +
    {/if}