First working POC
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { goto } from "$app/navigation";
|
||||
import { queue } from "$lib/stores/queue";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { currentMedia } from "$lib/stores/player";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import DownloadButton from "./DownloadButton.svelte";
|
||||
import Portal from "$lib/components/Portal.svelte";
|
||||
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
||||
|
||||
/** Queue context for remote transfer - what type of queue is this? */
|
||||
export type QueueContext =
|
||||
| { type: "album"; albumId: string; albumName: string }
|
||||
| { type: "playlist"; playlistId: string; playlistName: string }
|
||||
| { type: "custom" };
|
||||
|
||||
interface Props {
|
||||
tracks: MediaItem[];
|
||||
loading?: boolean;
|
||||
showAlbum?: boolean;
|
||||
showArtist?: boolean;
|
||||
showDownload?: boolean;
|
||||
/** Context for the queue - used for remote playback transfer */
|
||||
context?: QueueContext;
|
||||
onTrackClick?: (track: MediaItem, index: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
tracks,
|
||||
loading = false,
|
||||
showAlbum = true,
|
||||
showArtist = true,
|
||||
showDownload = false,
|
||||
context,
|
||||
onTrackClick
|
||||
}: Props = $props();
|
||||
|
||||
let isPlayingTrack = $state<string | null>(null);
|
||||
let openMenuId = $state<string | null>(null);
|
||||
let menuPosition = $state<MenuPosition | null>(null);
|
||||
|
||||
// Track which track is currently playing (from player store)
|
||||
const currentlyPlayingId = $derived($currentMedia?.id ?? null);
|
||||
|
||||
// Default internal handler for playing tracks directly
|
||||
async function defaultHandleTrackClick(track: MediaItem, index: number) {
|
||||
try {
|
||||
isPlayingTrack = track.id;
|
||||
|
||||
// Validate auth before proceeding
|
||||
const repo = auth.getRepository();
|
||||
if (!repo) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
// If this is an album, use the backend album command (more efficient)
|
||||
if (context && context.type === "album") {
|
||||
const repositoryHandle = repo.getHandle();
|
||||
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
|
||||
await invoke("player_play_album_track", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
albumId: context.albumId,
|
||||
albumName: context.albumName,
|
||||
trackId: track.id,
|
||||
shuffle: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use new backend command for non-album contexts (playlists, custom queues, etc.)
|
||||
// Backend handles all metadata fetching and queue building
|
||||
const repositoryHandle = repo.getHandle();
|
||||
const trackIds = tracks.map((t) => t.id);
|
||||
|
||||
// Determine context for queue
|
||||
let playContext;
|
||||
if (context?.type === "playlist") {
|
||||
playContext = {
|
||||
type: "playlist",
|
||||
playlistId: context.playlistId,
|
||||
playlistName: context.playlistName,
|
||||
};
|
||||
} else {
|
||||
playContext = { type: "custom" };
|
||||
}
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds,
|
||||
startIndex: index,
|
||||
shuffle: false,
|
||||
context: playContext,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
} catch (e) {
|
||||
console.error("Failed to play track:", e);
|
||||
alert(`Failed to play track: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
isPlayingTrack = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified handler that delegates to custom callback or default
|
||||
async function handleTrackClick(track: MediaItem, index: number) {
|
||||
if (onTrackClick) {
|
||||
await onTrackClick(track, index);
|
||||
} else {
|
||||
await defaultHandleTrackClick(track, index);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "-";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (openMenuId === trackId) {
|
||||
openMenuId = null;
|
||||
menuPosition = null;
|
||||
} else {
|
||||
openMenuId = trackId;
|
||||
menuPosition = calculateMenuPosition(buttonElement, 160, 120);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (openMenuId !== null) {
|
||||
openMenuId = null;
|
||||
menuPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleArtistClick(artistId: string, e: Event) {
|
||||
e.stopPropagation();
|
||||
goto(`/library/${artistId}`);
|
||||
}
|
||||
|
||||
function handleAlbumClick(albumId: string | undefined, e: Event) {
|
||||
if (!albumId) return;
|
||||
e.stopPropagation();
|
||||
goto(`/library/${albumId}`);
|
||||
}
|
||||
|
||||
async function addToQueue(track: MediaItem, position: "next" | "end", e: Event) {
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
try {
|
||||
// Queue store now handles everything in Rust - just pass the track
|
||||
await queue.addToQueue(track, position);
|
||||
console.log(`Added "${track.name}" to queue (${position})`);
|
||||
} catch (e) {
|
||||
console.error("Failed to add to queue:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(10) as _}
|
||||
<div
|
||||
class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4"
|
||||
>
|
||||
<div class="w-12 h-12 bg-gray-700 rounded"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-700 rounded w-1/3"></div>
|
||||
<div class="h-3 bg-gray-700 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tracks.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No tracks found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Table Header (Desktop) -->
|
||||
<div
|
||||
class="hidden md:grid gap-4 px-4 py-2 text-sm text-gray-400 border-b border-gray-700"
|
||||
style="grid-template-columns: auto 2fr {showArtist ? '1.5fr' : ''} {showAlbum
|
||||
? '1.5fr'
|
||||
: ''} auto {showDownload ? 'auto' : ''} auto;"
|
||||
>
|
||||
<div class="w-12">#</div>
|
||||
<div>Title</div>
|
||||
{#if showArtist}
|
||||
<div>Artist</div>
|
||||
{/if}
|
||||
{#if showAlbum}
|
||||
<div>Album</div>
|
||||
{/if}
|
||||
<div class="text-right">Duration</div>
|
||||
{#if showDownload}
|
||||
<div class="w-12"></div>
|
||||
{/if}
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<!-- Track Rows -->
|
||||
<div class="space-y-1">
|
||||
{#each tracks as track, index (track.id)}
|
||||
<div class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
|
||||
<!-- Desktop View -->
|
||||
<button
|
||||
onclick={() => handleTrackClick(track, index)}
|
||||
disabled={isPlayingTrack !== null}
|
||||
class="hidden md:grid gap-4 px-4 py-3 items-center w-full text-left cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style="grid-template-columns: auto 2fr {showArtist ? '1.5fr' : ''} {showAlbum
|
||||
? '1.5fr'
|
||||
: ''} auto {showDownload ? 'auto' : ''} auto;"
|
||||
>
|
||||
<!-- Index/Play Button -->
|
||||
<div class="w-12 flex items-center justify-center">
|
||||
{#if isPlayingTrack === track.id}
|
||||
<div class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<span class="group-hover:hidden text-gray-400">{index + 1}</span>
|
||||
<svg
|
||||
class="hidden group-hover:block w-5 h-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="min-w-0 flex items-center gap-2">
|
||||
{#if currentlyPlayingId === track.id}
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"></div>
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 150ms"></div>
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 300ms"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||
>
|
||||
{track.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Artist -->
|
||||
{#if showArtist}
|
||||
<div class="text-gray-300 truncate flex flex-wrap items-center gap-1">
|
||||
{#if track.artistItems && track.artistItems.length > 0}
|
||||
{#each track.artistItems as artist, idx}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline truncate"
|
||||
>
|
||||
{artist.name}
|
||||
</button>
|
||||
{#if idx < track.artistItems.length - 1}
|
||||
<span>,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{track.artists?.join(", ") || "-"}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Album -->
|
||||
{#if showAlbum}
|
||||
<div class="text-gray-300 truncate">
|
||||
{#if track.albumId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline truncate"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
</button>
|
||||
{:else}
|
||||
{track.albumName || "-"}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-gray-400 text-right">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
</div>
|
||||
|
||||
<!-- Download Button Placeholder -->
|
||||
{#if showDownload}
|
||||
<div class="w-12"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Menu Button Placeholder -->
|
||||
<div class="w-10"></div>
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons (separate from clickable area) -->
|
||||
<div class="hidden md:flex items-center gap-1 absolute right-4 top-1/2 -translate-y-1/2">
|
||||
{#if showDownload}
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<DownloadButton itemId={track.id} itemName={track.name} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- More Options Menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
toggleMenu(track.id, e.currentTarget, e);
|
||||
}}
|
||||
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile View -->
|
||||
<button
|
||||
onclick={() => handleTrackClick(track, index)}
|
||||
disabled={isPlayingTrack !== null}
|
||||
class="md:hidden flex items-center gap-3 px-4 py-3 w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<!-- Track Number -->
|
||||
<div class="w-8 flex-shrink-0 text-center">
|
||||
{#if isPlayingTrack === track.id}
|
||||
<div class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
{:else}
|
||||
<span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span>
|
||||
<svg
|
||||
class="hidden group-hover:block w-4 h-4 text-white mx-auto"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||
>
|
||||
{#if currentlyPlayingId === track.id}
|
||||
<span class="inline-block mr-1">▶</span>
|
||||
{/if}
|
||||
{track.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
|
||||
{#if showArtist && showAlbum}
|
||||
{#if track.artistItems && track.artistItems.length > 0}
|
||||
{#each track.artistItems as artist, idx}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</button>
|
||||
{#if idx < track.artistItems.length - 1}
|
||||
<span>,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{track.artists?.join(", ") || "-"}
|
||||
{/if}
|
||||
<span>•</span>
|
||||
{#if track.albumId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
</button>
|
||||
{:else}
|
||||
{track.albumName || "-"}
|
||||
{/if}
|
||||
{:else if showArtist}
|
||||
{#if track.artistItems && track.artistItems.length > 0}
|
||||
{#each track.artistItems as artist, idx}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</button>
|
||||
{#if idx < track.artistItems.length - 1}
|
||||
<span>,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{track.artists?.join(", ") || "-"}
|
||||
{/if}
|
||||
{:else if showAlbum}
|
||||
{#if track.albumId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
</button>
|
||||
{:else}
|
||||
{track.albumName || "-"}
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Action Buttons (absolute positioned to avoid creating new line) -->
|
||||
<div class="md:hidden flex items-center gap-1 absolute right-4 top-1/2 -translate-y-1/2">
|
||||
{#if showDownload}
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<DownloadButton itemId={track.id} itemName={track.name} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile More Options Menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
toggleMenu(track.id, e.currentTarget, e);
|
||||
}}
|
||||
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Portal Menu (rendered at document.body to avoid overflow clipping) -->
|
||||
{#if openMenuId && menuPosition}
|
||||
{@const selectedTrack = tracks.find(t => t.id === openMenuId)}
|
||||
{#if selectedTrack}
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed py-1 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 z-50 min-w-40"
|
||||
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => addToQueue(selectedTrack, "next", e)}
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Play Next
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => addToQueue(selectedTrack, "end", e)}
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Add to Queue
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
<svelte:window onclick={closeMenu} />
|
||||
Reference in New Issue
Block a user