jellytau/src/lib/components/library/TrackList.svelte
Duncan Tourolle 17a35573a0
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s
feat(library): focused music/TV/movie landing screens + self-draining download queue
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
  persist the resolved stream URL + target dir on each row (migration
  017), and the pump starts up to max_concurrent and drains the rest
  automatically as slots free, instead of the frontend silently dropping
  items past the concurrency limit. Album/series/season buttons now
  enqueue rather than calling start_download directly.

Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
  cache+server union via a request-id-tagged search-event, so superseded
  queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
2026-06-24 20:44:17 +02:00

522 lines
19 KiB
Svelte

<script lang="ts">
import { commands } from "$lib/api/bindings";
import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue";
import { auth } from "$lib/stores/auth";
import { currentMedia } from "$lib/stores/player";
import { toast } from "$lib/stores/toast";
import type { MediaItem } from "$lib/api/types";
import DownloadButton from "./DownloadButton.svelte";
import Portal from "$lib/components/Portal.svelte";
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
import { formatDuration } from "$lib/utils/duration";
/** 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);
let addToPlaylistTrackId = $state<string | 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 commands.playerPlayAlbumTrack(repositoryHandle, {
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: PlayTracksContext;
if (context?.type === "playlist") {
playContext = {
type: "playlist",
playlistId: context.playlistId,
playlistName: context.playlistName,
};
} else {
playContext = { type: "custom", label: null };
}
await commands.playerPlayTracks(repositoryHandle, {
trackIds,
startIndex: index,
shuffle: false,
context: playContext,
});
// Queue will auto-update from Rust backend event
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
console.error("Failed to play track:", errorMessage);
toast.error(`Failed to play track: ${errorMessage}`, 5000);
} 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 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 | null | 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 data-grid-index={index} 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}
<span
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{artist.name}
</span>
{#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}
<span
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{track.albumName || "-"}
</span>
{: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}
<span
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
</span>
{#if idx < track.artistItems.length - 1}
<span>,</span>
{/if}
{/each}
{:else}
{track.artists?.join(", ") || "-"}
{/if}
<span></span>
{#if track.albumId}
<span
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
</span>
{:else}
{track.albumName || "-"}
{/if}
{:else if showArtist}
{#if track.artistItems && track.artistItems.length > 0}
{#each track.artistItems as artist, idx}
<span
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
</span>
{#if idx < track.artistItems.length - 1}
<span>,</span>
{/if}
{/each}
{:else}
{track.artists?.join(", ") || "-"}
{/if}
{:else if showAlbum}
{#if track.albumId}
<span
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
</span>
{: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>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
addToPlaylistTrackId = selectedTrack.id;
closeMenu();
}}
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="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
Add to Playlist
</button>
</div>
</Portal>
{/if}
{/if}
<!-- Add to Playlist Modal -->
<AddToPlaylistModal
isOpen={addToPlaylistTrackId !== null}
onClose={() => addToPlaylistTrackId = null}
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
/>
<!-- Click outside to close menu -->
<svelte:window onclick={closeMenu} />