Files
shark/src/lib/components/SpotifyCollectionView.svelte
2026-03-18 11:08:23 -04:00

364 lines
8.9 KiB
Svelte

<script lang="ts">
import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte';
import { deezerAuth } from '$lib/stores/deezer';
interface Props {
title: string;
subtitle?: string;
metadata?: string;
coverImageUrl?: string;
tracks: Track[];
selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
lastCached?: number | null;
downloadingTrackIds?: Set<string>;
}
let {
title,
subtitle,
metadata,
coverImageUrl,
tracks,
selectedTrackIndex = null,
onTrackClick,
onDownloadTrack,
onDownloadPlaylist,
onRefresh,
refreshing = false,
lastCached = null,
downloadingTrackIds = new Set()
}: Props = $props();
function formatTimestamp(timestamp: number | null): string {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
function handleTrackClick(index: number) {
if (onTrackClick) {
onTrackClick(index);
}
}
function handleDownloadClick(index: number, event: MouseEvent) {
event.stopPropagation();
if (onDownloadTrack) {
onDownloadTrack(index);
}
}
function isTrackDownloading(track: Track): boolean {
const trackId = (track as any).spotifyId?.toString();
if (!trackId) return false;
return downloadingTrackIds.has(trackId);
}
</script>
<PageDecoration label="SPOTIFY PLAYLIST" />
<!-- Header -->
<div class="collection-header">
{#if coverImageUrl}
<img
src={coverImageUrl}
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 -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Track Listing -->
<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>
{#if $deezerAuth.loggedIn}
<th style="width: 100px;">Actions</th>
{/if}
</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 ?? '—'}</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>
{#if $deezerAuth.loggedIn}
<td class="actions">
<button
onclick={(e) => handleDownloadClick(i, e)}
disabled={isTrackDownloading(track)}
class="download-btn"
>
{isTrackDownloading(track) ? 'Queued' : 'Download'}
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'info'}
<!-- Playlist Info -->
<div class="info-container">
<fieldset>
<legend>Playlist Information</legend>
<div class="field-row">
<span class="field-label">Title:</span>
<span>{title}</span>
</div>
{#if subtitle}
<div class="field-row">
<span class="field-label">Creator:</span>
<span>{subtitle}</span>
</div>
{/if}
<div class="field-row">
<span class="field-label">Tracks:</span>
<span>{tracks.length}</span>
</div>
{#if lastCached}
<div class="field-row">
<span class="field-label">Last updated:</span>
<span>{formatTimestamp(lastCached)}</span>
</div>
{/if}
</fieldset>
{#if $deezerAuth.loggedIn}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<div class="actions-row">
<div>
<button onclick={onDownloadPlaylist}>Download Playlist</button>
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
</div>
{#if onRefresh}
<button onclick={onRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Tracks'}
</button>
{/if}
</div>
</fieldset>
{:else}
<fieldset style="margin-top: 16px;">
<legend>Downloads</legend>
<div class="actions-row">
<p class="warning-text">Deezer login required to download Spotify tracks</p>
{#if onRefresh}
<button onclick={onRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Tracks'}
</button>
{/if}
</div>
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
</fieldset>
{/if}
</div>
{/if}
</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;
z-index: 0;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.info-container {
padding: 16px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 120px;
}
.help-text {
margin: 8px 0 0 0;
font-size: 11px;
color: #808080;
}
.actions-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.warning-text {
margin: 0 0 8px 0;
font-size: 12px;
color: #c00;
}
.actions {
text-align: center;
}
.download-btn {
padding: 2px 8px;
font-size: 11px;
}
</style>