mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
feat(layout): playlist cover art, album UI
This commit is contained in:
136
src/lib/library/album.ts
Normal file
136
src/lib/library/album.ts
Normal file
@@ -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<TrackMetadata> {
|
||||||
|
try {
|
||||||
|
const fileData = await readFile(filePath);
|
||||||
|
|
||||||
|
const mimeMap: Record<AudioFormat, string> = {
|
||||||
|
'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<string | undefined> {
|
||||||
|
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<Track[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { parseBuffer } from 'music-metadata';
|
||||||
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
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<string | undefined> {
|
||||||
|
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
|
* Load playlist with track information
|
||||||
*/
|
*/
|
||||||
|
|||||||
263
src/routes/albums/[artist]/[album]/+page.svelte
Normal file
263
src/routes/albums/[artist]/[album]/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
|
import { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
let tracks = $state<Track[]>([]);
|
||||||
|
let coverArtPath = $state<string | undefined>(undefined);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
let artistName = $derived(decodeURIComponent($page.params.artist ?? ''));
|
||||||
|
let albumName = $derived(decodeURIComponent($page.params.album ?? ''));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadSettings();
|
||||||
|
await loadAlbum();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAlbum() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
if (!$settings.musicFolder) {
|
||||||
|
error = 'Music folder not configured. Please set it in Settings.';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const albumPath = `${$settings.musicFolder}/${artistName}/${albumName}`;
|
||||||
|
|
||||||
|
const [tracksData, coverPath] = await Promise.all([
|
||||||
|
loadAlbumTracks(albumPath),
|
||||||
|
findAlbumArt(albumPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
tracks = tracksData;
|
||||||
|
coverArtPath = coverPath;
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
error = 'No tracks found in this album.';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error loading album: ' + (e as Error).message;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThumbnailUrl(coverPath?: string): string {
|
||||||
|
if (!coverPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return convertFileSrc(coverPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
selectedTrackIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumYear(): string {
|
||||||
|
const year = tracks.find(t => t.metadata.year)?.metadata.year;
|
||||||
|
return year ? year.toString() : '—';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="album-wrapper">
|
||||||
|
{#if loading}
|
||||||
|
<p style="padding: 8px;">Loading album...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Album Header -->
|
||||||
|
<div class="album-header">
|
||||||
|
{#if coverArtPath}
|
||||||
|
<img
|
||||||
|
src={getThumbnailUrl(coverArtPath)}
|
||||||
|
alt="{albumName} cover"
|
||||||
|
class="album-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="album-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="album-info">
|
||||||
|
<h2>{albumName}</h2>
|
||||||
|
<p class="album-artist">{artistName}</p>
|
||||||
|
<p class="album-meta">
|
||||||
|
{getAlbumYear()} • {tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="album-content">
|
||||||
|
<!-- Tabs (single tab for tracks) -->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={true}>
|
||||||
|
<button>Tracks</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Track Listing -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">#</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Format</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === i}
|
||||||
|
onclick={() => handleTrackClick(i)}
|
||||||
|
>
|
||||||
|
<td class="track-number">
|
||||||
|
{track.metadata.trackNumber ?? i + 1}
|
||||||
|
</td>
|
||||||
|
<td>{track.metadata.title || track.filename}</td>
|
||||||
|
<td class="duration">
|
||||||
|
{#if track.metadata.duration}
|
||||||
|
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="format">{track.format.toUpperCase()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.album-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 8px 0 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover-placeholder {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-artist {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-meta {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
|
import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
|
||||||
@@ -56,8 +57,11 @@
|
|||||||
selectedArtistIndex = index;
|
selectedArtistIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlbumClick(index: number) {
|
function handleAlbumClick(album: Album, index: number) {
|
||||||
selectedAlbumIndex = index;
|
selectedAlbumIndex = index;
|
||||||
|
const artistEncoded = encodeURIComponent(album.artist);
|
||||||
|
const albumEncoded = encodeURIComponent(album.title);
|
||||||
|
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalTracks(artist: ArtistWithAlbums): number {
|
function getTotalTracks(artist: ArtistWithAlbums): number {
|
||||||
@@ -143,7 +147,7 @@
|
|||||||
{#each albums as album, i}
|
{#each albums as album, i}
|
||||||
<tr
|
<tr
|
||||||
class:highlighted={selectedAlbumIndex === i}
|
class:highlighted={selectedAlbumIndex === i}
|
||||||
onclick={() => handleAlbumClick(i)}
|
onclick={() => handleAlbumClick(album, i)}
|
||||||
>
|
>
|
||||||
<td class="cover-cell">
|
<td class="cover-cell">
|
||||||
{#if album.coverArtPath}
|
{#if album.coverArtPath}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import { scanPlaylists } from '$lib/library/scanner';
|
import { scanPlaylists } from '$lib/library/scanner';
|
||||||
import { loadPlaylistTracks } from '$lib/library/playlist';
|
import { loadPlaylistTracks, findPlaylistArt } from '$lib/library/playlist';
|
||||||
import type { Track, PlaylistWithTracks } from '$lib/types/track';
|
import type { Track, PlaylistWithTracks } from '$lib/types/track';
|
||||||
|
|
||||||
let playlistData = $state<PlaylistWithTracks | null>(null);
|
let playlistData = $state<PlaylistWithTracks | null>(null);
|
||||||
|
let coverArtPath = $state<string | undefined>(undefined);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
|
|
||||||
let playlistName = $derived(decodeURIComponent($page.params.name ?? ''));
|
let playlistName = $derived(decodeURIComponent($page.params.name ?? ''));
|
||||||
|
|
||||||
@@ -38,12 +41,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tracks from the playlist file
|
// Load tracks and cover art in parallel
|
||||||
playlistData = await loadPlaylistTracks(
|
const [tracksData, coverPath] = await Promise.all([
|
||||||
playlist.path,
|
loadPlaylistTracks(playlist.path, playlist.name, $settings.musicFolder),
|
||||||
playlist.name,
|
findPlaylistArt(playlist.path)
|
||||||
$settings.musicFolder
|
]);
|
||||||
);
|
|
||||||
|
playlistData = tracksData;
|
||||||
|
coverArtPath = coverPath;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,25 +56,62 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThumbnailUrl(coverPath?: string): string {
|
||||||
|
if (!coverPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return convertFileSrc(coverPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
selectedTrackIndex = index;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div class="playlist-wrapper">
|
||||||
<h2>{playlistName}</h2>
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p>Loading playlist...</p>
|
<p style="padding: 8px;">Loading playlist...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error">{error}</p>
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
{:else if playlistData && playlistData.tracks.length === 0}
|
{:else if playlistData && playlistData.tracks.length === 0}
|
||||||
<p>No tracks in this playlist.</p>
|
<p style="padding: 8px;">No tracks in this playlist.</p>
|
||||||
{:else if playlistData}
|
{:else if playlistData}
|
||||||
<section class="playlist-content">
|
<!-- Playlist Header -->
|
||||||
<p class="track-count">{playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}</p>
|
<div class="playlist-header">
|
||||||
|
{#if coverArtPath}
|
||||||
|
<img
|
||||||
|
src={getThumbnailUrl(coverArtPath)}
|
||||||
|
alt="{playlistName} cover"
|
||||||
|
class="playlist-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="playlist-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="playlist-info">
|
||||||
|
<h2>{playlistName}</h2>
|
||||||
|
<p class="playlist-meta">
|
||||||
|
{playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<section class="playlist-content">
|
||||||
|
<!-- Tabs (single tab for tracks) -->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={true}>
|
||||||
|
<button>Tracks</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Track Listing -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 40px;">#</th>
|
<th style="width: 50px;">#</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Artist</th>
|
<th>Artist</th>
|
||||||
<th>Album</th>
|
<th>Album</th>
|
||||||
@@ -79,11 +121,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each playlistData.tracks as track, index}
|
{#each playlistData.tracks as track, index}
|
||||||
<tr>
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === index}
|
||||||
|
onclick={() => handleTrackClick(index)}
|
||||||
|
>
|
||||||
<td class="track-number">{index + 1}</td>
|
<td class="track-number">{index + 1}</td>
|
||||||
<td>{track.metadata.title || track.filename}</td>
|
<td>{track.metadata.title || track.filename}</td>
|
||||||
<td>{track.metadata.artist || '—'}</td>
|
<td>{track.metadata.artist || '—'}</td>
|
||||||
<td class="album">{track.metadata.album || '—'}</td>
|
<td>{track.metadata.album || '—'}</td>
|
||||||
<td class="duration">
|
<td class="duration">
|
||||||
{#if track.metadata.duration}
|
{#if track.metadata.duration}
|
||||||
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||||
@@ -96,46 +141,113 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2 {
|
.playlist-wrapper {
|
||||||
margin-top: 0;
|
height: 100%;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
.playlist-content {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-count {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 8px 0 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-cover {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-cover-placeholder {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.track-number {
|
.track-number {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format {
|
.format {
|
||||||
@@ -143,5 +255,6 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user