mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
refactor(ui): add CollectionView component and unify track listing views
This commit is contained in:
232
src/lib/components/CollectionView.svelte
Normal file
232
src/lib/components/CollectionView.svelte
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metadata?: string;
|
||||||
|
coverArtPath?: string;
|
||||||
|
tracks: Track[];
|
||||||
|
selectedTrackIndex?: number | null;
|
||||||
|
onTrackClick?: (index: number) => void;
|
||||||
|
showAlbumColumn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
metadata,
|
||||||
|
coverArtPath,
|
||||||
|
tracks,
|
||||||
|
selectedTrackIndex = null,
|
||||||
|
onTrackClick,
|
||||||
|
showAlbumColumn = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function getThumbnailUrl(coverPath?: string): string {
|
||||||
|
if (!coverPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return convertFileSrc(coverPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
if (onTrackClick) {
|
||||||
|
onTrackClick(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="collection-header">
|
||||||
|
{#if coverArtPath}
|
||||||
|
<img
|
||||||
|
src={getThumbnailUrl(coverArtPath)}
|
||||||
|
alt="{title} cover"
|
||||||
|
class="collection-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="collection-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="collection-info">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="collection-subtitle">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
{#if metadata}
|
||||||
|
<p class="collection-metadata">{metadata}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="collection-content">
|
||||||
|
<!-- Tabs (single tab for tracks) -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
||||||
|
The <menu> element IS interactive (contains clickable <button> elements) and the
|
||||||
|
role="tablist" properly describes the semantic purpose to assistive technology.
|
||||||
|
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
||||||
|
-->
|
||||||
|
<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>
|
||||||
|
{#if showAlbumColumn}
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
{/if}
|
||||||
|
<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>
|
||||||
|
{#if showAlbumColumn}
|
||||||
|
<td>{track.metadata.artist || '—'}</td>
|
||||||
|
<td>{track.metadata.album || '—'}</td>
|
||||||
|
{/if}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover-placeholder {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-subtitle {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-metadata {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-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,9 +1,9 @@
|
|||||||
<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 { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
|
import { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
|
||||||
|
import CollectionView from '$lib/components/CollectionView.svelte';
|
||||||
import type { Track } from '$lib/types/track';
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
let tracks = $state<Track[]>([]);
|
let tracks = $state<Track[]>([]);
|
||||||
@@ -52,13 +52,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThumbnailUrl(coverPath?: string): string {
|
|
||||||
if (!coverPath) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return convertFileSrc(coverPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTrackClick(index: number) {
|
function handleTrackClick(index: number) {
|
||||||
selectedTrackIndex = index;
|
selectedTrackIndex = index;
|
||||||
}
|
}
|
||||||
@@ -67,86 +60,31 @@
|
|||||||
const year = tracks.find(t => t.metadata.year)?.metadata.year;
|
const year = tracks.find(t => t.metadata.year)?.metadata.year;
|
||||||
return year ? year.toString() : '—';
|
return year ? year.toString() : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let metadata = $derived(`${getAlbumYear()} • ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="album-wrapper">
|
<div class="wrapper">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p style="padding: 8px;">Loading album...</p>
|
<p style="padding: 8px;">Loading album...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error" style="padding: 8px;">{error}</p>
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Album Header -->
|
<CollectionView
|
||||||
<div class="album-header">
|
title={albumName}
|
||||||
{#if coverArtPath}
|
subtitle={artistName}
|
||||||
<img
|
{metadata}
|
||||||
src={getThumbnailUrl(coverArtPath)}
|
{coverArtPath}
|
||||||
alt="{albumName} cover"
|
{tracks}
|
||||||
class="album-cover"
|
{selectedTrackIndex}
|
||||||
/>
|
onTrackClick={handleTrackClick}
|
||||||
{:else}
|
showAlbumColumn={false}
|
||||||
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.album-wrapper {
|
.wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -155,109 +93,4 @@
|
|||||||
.error {
|
.error {
|
||||||
color: #ff6b6b;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -81,6 +81,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<section class="library-content">
|
<section class="library-content">
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
||||||
|
The <menu> element IS interactive (contains clickable <button> elements) and the
|
||||||
|
role="tablist" properly describes the semantic purpose to assistive technology.
|
||||||
|
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
||||||
|
-->
|
||||||
<menu role="tablist">
|
<menu role="tablist">
|
||||||
<li role="tab" aria-selected={viewMode === 'artists'}>
|
<li role="tab" aria-selected={viewMode === 'artists'}>
|
||||||
<button onclick={() => viewMode = 'artists'}>Artists</button>
|
<button onclick={() => viewMode = 'artists'}>Artists</button>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<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, findPlaylistArt } from '$lib/library/playlist';
|
import { loadPlaylistTracks, findPlaylistArt } from '$lib/library/playlist';
|
||||||
|
import CollectionView from '$lib/components/CollectionView.svelte';
|
||||||
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);
|
||||||
@@ -57,19 +57,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThumbnailUrl(coverPath?: string): string {
|
|
||||||
if (!coverPath) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return convertFileSrc(coverPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTrackClick(index: number) {
|
function handleTrackClick(index: number) {
|
||||||
selectedTrackIndex = index;
|
selectedTrackIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let metadata = $derived(playlistData ? `${playlistData.tracks.length} track${playlistData.tracks.length !== 1 ? 's' : ''}` : '');
|
||||||
|
let tracks = $derived(playlistData?.tracks ?? []);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="playlist-wrapper">
|
<div class="wrapper">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p style="padding: 8px;">Loading playlist...</p>
|
<p style="padding: 8px;">Loading playlist...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -77,79 +73,20 @@
|
|||||||
{:else if playlistData && playlistData.tracks.length === 0}
|
{:else if playlistData && playlistData.tracks.length === 0}
|
||||||
<p style="padding: 8px;">No tracks in this playlist.</p>
|
<p style="padding: 8px;">No tracks in this playlist.</p>
|
||||||
{:else if playlistData}
|
{:else if playlistData}
|
||||||
<!-- Playlist Header -->
|
<CollectionView
|
||||||
<div class="playlist-header">
|
title={playlistName}
|
||||||
{#if coverArtPath}
|
{metadata}
|
||||||
<img
|
coverArtPath={coverArtPath}
|
||||||
src={getThumbnailUrl(coverArtPath)}
|
{tracks}
|
||||||
alt="{playlistName} cover"
|
{selectedTrackIndex}
|
||||||
class="playlist-cover"
|
onTrackClick={handleTrackClick}
|
||||||
/>
|
showAlbumColumn={true}
|
||||||
{: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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 50px;">#</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Artist</th>
|
|
||||||
<th>Album</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Format</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each playlistData.tracks as track, index}
|
|
||||||
<tr
|
|
||||||
class:highlighted={selectedTrackIndex === index}
|
|
||||||
onclick={() => handleTrackClick(index)}
|
|
||||||
>
|
|
||||||
<td class="track-number">{index + 1}</td>
|
|
||||||
<td>{track.metadata.title || track.filename}</td>
|
|
||||||
<td>{track.metadata.artist || '—'}</td>
|
|
||||||
<td>{track.metadata.album || '—'}</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.playlist-wrapper {
|
.wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -158,103 +95,4 @@
|
|||||||
.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 {
|
|
||||||
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user