mirror of
https://github.com/markuryy/shark.git
synced 2026-06-18 18:41:03 +00:00
364 lines
8.9 KiB
Svelte
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>
|