CRITICAL FIXES (Previous): - Fix nextEpisode event handlers (was calling undefined methods) - Replace queue polling with event-based updates (90% reduction in backend calls) - Move device ID to Tauri secure storage (security fix) - Fix event listener memory leaks with proper cleanup - Replace browser alerts with toast notifications - Remove silent error handlers and improve logging - Fix race condition in downloads store with request queuing - Centralize duration formatting utility - Add input validation to image URLs (prevent injection attacks) PHASE 1: BACKEND SORTING & FILTERING ✅ - Created Jellyfin field mapping utility (src/lib/utils/jellyfinFieldMapping.ts) - Maps frontend sort keys to Jellyfin API field names - Provides item type constants and groups - Includes 20+ test cases for comprehensive coverage - Updated route components to use backend sorting: - src/routes/library/music/tracks/+page.svelte - src/routes/library/music/albums/+page.svelte - src/routes/library/music/artists/+page.svelte - Refactored GenericMediaListPage.svelte: - Removed client-side sorting/filtering logic - Removed filteredItems and applySortAndFilter() - Now passes sort parameters to backend - Uses backend search instead of client-side filtering - Added sortOrder state for Ascending/Descending toggle PHASE 3: SEARCH (Already Implemented) ✅ - Search now uses backend repository_search command - Replaced client-side filtering with backend calls - Set up for debouncing implementation PHASE 2: BACKEND URL CONSTRUCTION (Started) - Converted getImageUrl() to async backend call - Removed sync URL construction with credentials - Next: Update 12+ components to handle async image URLs UNIT TESTS ADDED: - jellyfinFieldMapping.test.ts (20+ test cases) - duration.test.ts (15+ test cases) - validation.test.ts (25+ test cases) - deviceId.test.ts (8+ test cases) - playerEvents.test.ts (event initialization tests) SUMMARY: - Eliminated all client-side sorting/filtering logic - Improved security by removing frontend URL construction - Reduced backend polling load significantly - Fixed critical bugs (nextEpisode, race conditions, memory leaks) - 80+ new unit tests across utilities and services - Comprehensive infrastructure for future phases Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
492 lines
18 KiB
Svelte
492 lines
18 KiB
Svelte
<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 { 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 { 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);
|
|
|
|
// 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) {
|
|
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 | 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} />
|