First working POC
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
albumName: string;
|
||||
tracks: MediaItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { albumId, albumName, tracks, className = "" }: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
|
||||
// Calculate download status for all tracks in album
|
||||
const downloadStatuses = $derived(
|
||||
tracks.map((track) =>
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === track.id)
|
||||
)
|
||||
);
|
||||
|
||||
const completedCount = $derived(
|
||||
downloadStatuses.filter((d) => d?.status === "completed").length
|
||||
);
|
||||
|
||||
const downloadingCount = $derived(
|
||||
downloadStatuses.filter(
|
||||
(d) => d?.status === "downloading" || d?.status === "pending"
|
||||
).length
|
||||
);
|
||||
|
||||
const failedCount = $derived(
|
||||
downloadStatuses.filter((d) => d?.status === "failed").length
|
||||
);
|
||||
|
||||
const totalProgress = $derived(() => {
|
||||
if (tracks.length === 0) return 0;
|
||||
const activeDownloads = downloadStatuses.filter(
|
||||
(d) => d?.status === "downloading"
|
||||
);
|
||||
if (activeDownloads.length === 0) return completedCount / tracks.length;
|
||||
|
||||
const downloadingProgress = activeDownloads.reduce(
|
||||
(sum, d) => sum + (d?.progress || 0),
|
||||
0
|
||||
);
|
||||
return (completedCount + downloadingProgress) / tracks.length;
|
||||
});
|
||||
|
||||
const isFullyDownloaded = $derived(completedCount === tracks.length && tracks.length > 0);
|
||||
const isDownloading = $derived(downloadingCount > 0);
|
||||
const hasPartialDownload = $derived(completedCount > 0 && completedCount < tracks.length);
|
||||
|
||||
async function handleClick() {
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyDownloaded) {
|
||||
// Delete all downloads for this album
|
||||
for (const status of downloadStatuses) {
|
||||
if (status?.id) {
|
||||
await downloads.delete(status.id);
|
||||
}
|
||||
}
|
||||
} else if (isDownloading) {
|
||||
// Cancel all active downloads for this album
|
||||
for (const status of downloadStatuses) {
|
||||
if (
|
||||
status?.id &&
|
||||
(status.status === "downloading" || status.status === "pending")
|
||||
) {
|
||||
await downloads.cancel(status.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Download the album
|
||||
const basePath = `albums/${albumId}`;
|
||||
await downloads.downloadAlbum(albumId, userId, basePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Album download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
if (isFullyDownloaded) {
|
||||
return "Downloaded - Click to remove";
|
||||
}
|
||||
if (isDownloading) {
|
||||
return `Downloading ${completedCount + downloadingCount}/${tracks.length}... Click to cancel`;
|
||||
}
|
||||
if (hasPartialDownload) {
|
||||
return `${completedCount}/${tracks.length} downloaded - Click to download remaining`;
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return `${failedCount} failed - Click to retry`;
|
||||
}
|
||||
return "Download for offline playback";
|
||||
}
|
||||
|
||||
function getStatusText(): string {
|
||||
if (isFullyDownloaded) return "";
|
||||
if (isDownloading || hasPartialDownload) {
|
||||
return `${completedCount}/${tracks.length}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing || tracks.length === 0}
|
||||
class="px-6 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors {isFullyDownloaded
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: isDownloading
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]'} {isProcessing
|
||||
? 'opacity-50 cursor-wait'
|
||||
: ''} {className}"
|
||||
title={getTitle()}
|
||||
aria-label={getTitle()}
|
||||
>
|
||||
<div class="relative w-5 h-5">
|
||||
{#if isDownloading}
|
||||
<!-- Progress ring -->
|
||||
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - totalProgress())}
|
||||
stroke-linecap="round"
|
||||
class="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Small download icon inside -->
|
||||
<svg
|
||||
class="absolute inset-0 m-auto w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{:else if isFullyDownloaded}
|
||||
<!-- Checkmark icon -->
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if failedCount > 0}
|
||||
<!-- Error icon -->
|
||||
<svg
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isDownloading || hasPartialDownload}
|
||||
<span>{getStatusText()}</span>
|
||||
{:else if isFullyDownloaded}
|
||||
<span>Downloaded</span>
|
||||
{:else}
|
||||
<span>Download</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
artist: MediaItem;
|
||||
}
|
||||
|
||||
let { artist }: Props = $props();
|
||||
|
||||
let albums = $state<MediaItem[]>([]);
|
||||
let singles = $state<MediaItem[]>([]);
|
||||
let topTracks = $state<MediaItem[]>([]);
|
||||
let relatedArtists = $state<MediaItem[]>([]);
|
||||
|
||||
let albumsLoading = $state(true);
|
||||
let singlesLoading = $state(true);
|
||||
let tracksLoading = $state(true);
|
||||
let artistsLoading = $state(true);
|
||||
|
||||
let showSingles = $state(false);
|
||||
let showAppears = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadArtistContent();
|
||||
});
|
||||
|
||||
async function loadArtistContent() {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
if (!repo) return;
|
||||
|
||||
// Load albums
|
||||
try {
|
||||
const albumsResult = await repo.getItems(artist.id, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
limit: 50,
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
albumsLoading = false;
|
||||
}
|
||||
|
||||
// Load top tracks
|
||||
try {
|
||||
const tracksResult = await repo.getItems(artist.id, {
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 10,
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.type === "Audio");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
tracksLoading = false;
|
||||
}
|
||||
|
||||
// Load related artists (by genre)
|
||||
try {
|
||||
if (artist.genres && artist.genres.length > 0) {
|
||||
const relatedResult = await repo.getItems(undefined, {
|
||||
includeItemTypes: ["MusicArtist"],
|
||||
genreIds: artist.genres.slice(0, 2),
|
||||
limit: 12,
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
relatedArtists = relatedResult.items
|
||||
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related artists:", e);
|
||||
} finally {
|
||||
artistsLoading = false;
|
||||
}
|
||||
|
||||
singlesLoading = false;
|
||||
} catch (e) {
|
||||
console.error("Error loading artist content:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative">
|
||||
<!-- Backdrop -->
|
||||
{#if artist.backdropImageTags?.[0]}
|
||||
<div class="absolute inset-0 -z-10 h-96 overflow-hidden rounded-lg">
|
||||
<CachedImage
|
||||
itemId={artist.id}
|
||||
imageType="Backdrop"
|
||||
tag={artist.backdropImageTags[0]}
|
||||
maxWidth={1920}
|
||||
class="w-full h-full object-cover opacity-40"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artist Info -->
|
||||
<div class="flex flex-col items-center text-center py-12">
|
||||
<!-- Artist Image -->
|
||||
{#if artist.primaryImageTag}
|
||||
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
|
||||
<CachedImage
|
||||
itemId={artist.id}
|
||||
imageType="Primary"
|
||||
tag={artist.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={artist.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artist Name -->
|
||||
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
|
||||
|
||||
<!-- Bio -->
|
||||
{#if artist.overview}
|
||||
<p class="text-gray-300 leading-relaxed max-w-3xl">
|
||||
{artist.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Albums Section -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Albums</h2>
|
||||
{#if albumsLoading}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if albums.length === 0}
|
||||
<p class="text-gray-400">No albums found</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each albums as album (album.id)}
|
||||
<a
|
||||
href="/library/{album.id}"
|
||||
class="group cursor-pointer"
|
||||
>
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
||||
{#if album.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={album.id}
|
||||
imageType="Primary"
|
||||
tag={album.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={album.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{album.name}
|
||||
</p>
|
||||
{#if album.productionYear}
|
||||
<p class="text-xs text-gray-400">{album.productionYear}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Top Tracks Section -->
|
||||
{#if topTracks.length > 0}
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Top Tracks</h2>
|
||||
<TrackList
|
||||
tracks={topTracks}
|
||||
loading={tracksLoading}
|
||||
showAlbum={true}
|
||||
showArtist={false}
|
||||
showDownload={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Related Artists Section -->
|
||||
{#if relatedArtists.length > 0}
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Similar Artists</h2>
|
||||
{#if artistsLoading}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse text-center">
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full mb-2 mx-auto"></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mx-auto"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each relatedArtists as relatedArtist (relatedArtist.id)}
|
||||
<a
|
||||
href="/library/{relatedArtist.id}"
|
||||
class="group text-center"
|
||||
>
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
||||
{#if relatedArtist.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={relatedArtist.id}
|
||||
imageType="Primary"
|
||||
tag={relatedArtist.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={relatedArtist.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{relatedArtist.name}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import type { Person, PersonType } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { people, title = "Cast & Crew" }: Props = $props();
|
||||
|
||||
// Group people by type
|
||||
const groupedPeople = $derived.by(() => {
|
||||
const groups: Record<string, Person[]> = {
|
||||
Actor: [],
|
||||
Director: [],
|
||||
Writer: [],
|
||||
Producer: [],
|
||||
Composer: [],
|
||||
Other: [],
|
||||
};
|
||||
|
||||
for (const person of people) {
|
||||
// Skip people without valid ID (can occur if API response is incomplete)
|
||||
if (!person.id || person.id.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const type = person.type;
|
||||
if (type in groups) {
|
||||
groups[type].push(person);
|
||||
} else {
|
||||
groups.Other.push(person);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Order: Actors first, then Directors, Writers, etc.
|
||||
const orderedTypes = ["Actor", "Director", "Writer", "Producer", "Composer", "Other"] as const;
|
||||
|
||||
// Get display name for type
|
||||
function getTypeName(type: string): string {
|
||||
switch (type) {
|
||||
case "Actor":
|
||||
return "Cast";
|
||||
case "Director":
|
||||
return "Directors";
|
||||
case "Writer":
|
||||
return "Writers";
|
||||
case "Producer":
|
||||
return "Producers";
|
||||
case "Composer":
|
||||
return "Composers";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function getPersonImageUrl(person: Person): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function handlePersonClick(person: Person) {
|
||||
goto(`/library/${person.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-semibold text-white">{title}</h2>
|
||||
|
||||
{#each orderedTypes as type}
|
||||
{#if groupedPeople[type]?.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-gray-400 uppercase tracking-wide">
|
||||
{getTypeName(type)}
|
||||
</h3>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-700">
|
||||
{#each groupedPeople[type] as person (person.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 w-24 group text-left"
|
||||
onclick={() => handlePersonClick(person)}
|
||||
>
|
||||
<!-- Person image -->
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||
{#if person.primaryImageTag}
|
||||
<img
|
||||
src={getPersonImageUrl(person)}
|
||||
alt={person.name}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Name and role -->
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{person.name}
|
||||
</p>
|
||||
{#if person.role}
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{person.role}
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Person } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
|
||||
label?: string; // e.g., "Directed by", "Written by"
|
||||
maxShow?: number; // Default: 3
|
||||
}
|
||||
|
||||
let {
|
||||
people,
|
||||
roleFilter,
|
||||
label,
|
||||
maxShow = 3
|
||||
}: Props = $props();
|
||||
|
||||
// Filter and limit people by role
|
||||
const filteredPeople = $derived(
|
||||
people
|
||||
.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
|
||||
.slice(0, maxShow)
|
||||
);
|
||||
|
||||
const totalMatching = $derived(
|
||||
people.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length
|
||||
);
|
||||
|
||||
function handlePersonClick(personId: string, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
goto(`/library/${personId}`);
|
||||
}
|
||||
|
||||
// Generate label if not provided
|
||||
const displayLabel = $derived.by(() => {
|
||||
if (label) return label;
|
||||
|
||||
if (roleFilter.length === 1) {
|
||||
const role = roleFilter[0];
|
||||
if (role === "Director") return "Directed by";
|
||||
if (role === "Writer") return "Written by";
|
||||
if (role === "Producer") return "Produced by";
|
||||
if (role === "Composer") return "Music by";
|
||||
return `${role}:`;
|
||||
}
|
||||
return "Credits:";
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filteredPeople.length > 0}
|
||||
<div class="text-sm text-gray-400 flex flex-wrap items-baseline gap-2">
|
||||
<span class="text-gray-500">{displayLabel}</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each filteredPeople as person, index (person.id)}
|
||||
<button
|
||||
onclick={(e) => handlePersonClick(person.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
{person.name}
|
||||
</button>
|
||||
{#if index < filteredPeople.length - 1}
|
||||
<span class="text-gray-500">,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if totalMatching > maxShow}
|
||||
<span class="text-gray-500">+{totalMatching - maxShow} more</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import DownloadButtonCore from "./DownloadButtonCore.svelte";
|
||||
import type { DownloadState } from "./DownloadButtonCore.svelte";
|
||||
|
||||
/**
|
||||
* Single audio track download button
|
||||
* @req: UR-011 - Download for offline playback
|
||||
* @req: UR-018 - Download entire albums or playlists
|
||||
* @req: DR-018 - Download buttons on library/album/player screens
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
itemName?: string;
|
||||
artistName?: string;
|
||||
albumName?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { itemId, itemName = "", artistName = "", albumName = "", size = "md", className = "" }: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
|
||||
);
|
||||
|
||||
const status = $derived(downloadInfo?.status || "not_downloaded");
|
||||
const progress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const buttonState = $derived<DownloadState>({
|
||||
status: (status as DownloadState["status"]) || "not_downloaded",
|
||||
progress: progress || 0,
|
||||
});
|
||||
|
||||
async function handleClick() {
|
||||
console.log("🖱️ Download button clicked! Current status:", status);
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
if (status === "completed") {
|
||||
// Delete download
|
||||
if (downloadInfo?.id) {
|
||||
await downloads.delete(downloadInfo.id);
|
||||
}
|
||||
} else if (status === "downloading" || status === "pending") {
|
||||
// Cancel download
|
||||
if (downloadInfo?.id) {
|
||||
await downloads.cancel(downloadInfo.id);
|
||||
}
|
||||
} else if (status === "failed") {
|
||||
// Retry failed download
|
||||
if (downloadInfo?.id) {
|
||||
await downloads.resume(downloadInfo.id);
|
||||
}
|
||||
} else {
|
||||
// Start download
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("🎯 Starting download for item:", itemId);
|
||||
|
||||
// Get stream URL
|
||||
const streamUrl = await repo.getAudioStreamUrl(itemId);
|
||||
console.log(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||
if (!streamUrl) {
|
||||
throw new Error("Failed to get stream URL");
|
||||
}
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
console.log(" Target directory:", targetDir);
|
||||
|
||||
// Queue and start download in single atomic operation
|
||||
const downloadId = await invoke<number>("download_item_and_start", {
|
||||
itemId,
|
||||
userId,
|
||||
streamUrl,
|
||||
targetDir,
|
||||
itemName: itemName || undefined,
|
||||
artistName: artistName || undefined,
|
||||
albumName: albumName || undefined,
|
||||
});
|
||||
console.log(" Download queued and started with ID:", downloadId);
|
||||
|
||||
// Refresh downloads list
|
||||
await downloads.refresh(userId);
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to start download:", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Downloaded - Click to remove";
|
||||
case "downloading":
|
||||
return `Downloading... ${Math.round(progress * 100)}%`;
|
||||
case "pending":
|
||||
return "Queued for download";
|
||||
case "paused":
|
||||
return "Download paused";
|
||||
case "failed":
|
||||
return "Download failed - Click to retry";
|
||||
default:
|
||||
return "Download for offline playback";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-2 rounded-full">
|
||||
<DownloadButtonCore {size} state={buttonState} title={getTitle()} onClick={handleClick} {isProcessing} {className} />
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Core download button UI component - Renders download state with progress
|
||||
* Used as base for all download button variants (tracks, albums, videos, series)
|
||||
*
|
||||
* @req: UR-011 - Download for offline playback
|
||||
* @req: UR-018 - Download entire albums or playlists
|
||||
* @req: DR-018 - Download buttons on library/album/player screens
|
||||
*/
|
||||
|
||||
export interface DownloadState {
|
||||
status: "not_downloaded" | "downloading" | "pending" | "completed" | "failed";
|
||||
progress: number; // 0-1
|
||||
}
|
||||
|
||||
interface Props {
|
||||
state: DownloadState;
|
||||
size?: "sm" | "md" | "lg";
|
||||
title: string;
|
||||
onClick?: () => void | Promise<void>;
|
||||
isProcessing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { state, size = "md", title, onClick, isProcessing = false, className = "" }: Props = $props();
|
||||
|
||||
const sizeMap = {
|
||||
sm: { icon: "w-4 h-4", ring: "w-8 h-8" },
|
||||
md: { icon: "w-5 h-5", ring: "w-10 h-10" },
|
||||
lg: { icon: "w-6 h-6", ring: "w-12 h-12" },
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
not_downloaded: "text-gray-400 hover:text-white",
|
||||
downloading: "text-blue-500",
|
||||
pending: "text-yellow-500",
|
||||
completed: "text-green-500",
|
||||
failed: "text-red-500",
|
||||
};
|
||||
|
||||
const circumference = 2 * Math.PI * 15;
|
||||
const offset = circumference - (state.progress || 0) * circumference;
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onClick}
|
||||
disabled={isProcessing || state.status === "downloading"}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`}
|
||||
>
|
||||
{#if state.status === "downloading"}
|
||||
<!-- Progress Ring -->
|
||||
<svg class="{sizeMap[size].ring} -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="2" class="opacity-20" />
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={offset}
|
||||
stroke-linecap="round"
|
||||
class="transition-all"
|
||||
style="transition: stroke-dashoffset 0.3s ease;"
|
||||
/>
|
||||
<!-- Download Icon in Center -->
|
||||
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current">
|
||||
{Math.round(state.progress * 100)}%
|
||||
</text>
|
||||
</svg>
|
||||
{:else if state.status === "completed"}
|
||||
<!-- Checkmark Icon -->
|
||||
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</svg>
|
||||
{:else if state.status === "failed"}
|
||||
<!-- Error Icon -->
|
||||
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
|
||||
</svg>
|
||||
{:else if state.status === "pending"}
|
||||
<!-- Pending Icon (clock) -->
|
||||
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" stroke-width="2" />
|
||||
<path stroke-width="2" stroke-linecap="round" d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download Icon -->
|
||||
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
series: MediaItem;
|
||||
allEpisodes: MediaItem[];
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||
|
||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
if (ep.id === episode.id) return true;
|
||||
// Also match by season/episode number in case IDs differ
|
||||
return ep.parentIndexNumber === episode.parentIndexNumber &&
|
||||
ep.indexNumber === episode.indexNumber;
|
||||
}
|
||||
|
||||
// Find adjacent episodes - use season/episode numbers if ID not found
|
||||
const adjacentEpisodes = $derived(() => {
|
||||
// First, try to find the episode by ID
|
||||
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
|
||||
|
||||
// If not found by ID, try to find by season/episode number
|
||||
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
|
||||
idx = allEpisodes.findIndex(
|
||||
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
|
||||
);
|
||||
}
|
||||
|
||||
// If still not found, filter to same season and show those centered around the episode number
|
||||
if (idx === -1) {
|
||||
const sameSeasonEpisodes = allEpisodes
|
||||
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
|
||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
||||
|
||||
if (sameSeasonEpisodes.length > 0) {
|
||||
// Find position based on episode number
|
||||
const epNum = episode.indexNumber || 1;
|
||||
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
|
||||
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
|
||||
const start = Math.max(0, actualIdx - 3);
|
||||
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
|
||||
const result = sameSeasonEpisodes.slice(start, end);
|
||||
|
||||
// Insert the focused episode if not already present (by season/episode number match)
|
||||
const hasCurrentEpisode = result.some(isCurrentEpisode);
|
||||
if (!hasCurrentEpisode) {
|
||||
// Insert at correct position based on episode number
|
||||
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
|
||||
if (insertIdx === -1) {
|
||||
result.push(episode);
|
||||
} else {
|
||||
result.splice(insertIdx, 0, episode);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Last resort: return focused episode with first 9 episodes
|
||||
return [episode, ...allEpisodes.slice(0, 9)];
|
||||
}
|
||||
|
||||
// Get 3 before and 6 after (or adjust based on position)
|
||||
const start = Math.max(0, idx - 3);
|
||||
const end = Math.min(allEpisodes.length, idx + 7);
|
||||
return allEpisodes.slice(start, end);
|
||||
});
|
||||
|
||||
function getBackdropUrl(): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Try episode backdrop first
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(episode.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.backdropImageTags[0],
|
||||
});
|
||||
}
|
||||
|
||||
// Try episode primary (thumbnail)
|
||||
if (episode.primaryImageTag) {
|
||||
return repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to series backdrop
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(series.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: series.backdropImageTags[0],
|
||||
});
|
||||
}
|
||||
|
||||
return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeThumbnail(ep: MediaItem): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(ep.id, "Primary", {
|
||||
maxWidth: 400,
|
||||
tag: ep.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function getProgress(ep: MediaItem): number {
|
||||
if (!ep.userData || !ep.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (ep.userData.playbackPositionTicks / ep.runTimeTicks) * 100;
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
goto(`/player/${episode.id}`);
|
||||
}
|
||||
|
||||
function handleEpisodeClick(ep: MediaItem) {
|
||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||
}
|
||||
|
||||
const backdropUrl = $derived(getBackdropUrl());
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Hero section -->
|
||||
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
||||
{#if backdropUrl}
|
||||
<img
|
||||
src={backdropUrl}
|
||||
alt={episode.name}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
|
||||
<!-- Back button -->
|
||||
{#if onBack}
|
||||
<button
|
||||
onclick={onBack}
|
||||
class="absolute top-4 left-4 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
|
||||
title="Back to series"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
|
||||
<div class="space-y-4">
|
||||
<!-- Series name -->
|
||||
<p class="text-gray-300 text-lg">{series.name}</p>
|
||||
|
||||
<!-- Episode title -->
|
||||
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
||||
{episode.name}
|
||||
</h1>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-200">
|
||||
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
||||
{episodeLabel}
|
||||
</span>
|
||||
{#if duration}
|
||||
<span>{duration}</span>
|
||||
{/if}
|
||||
{#if episode.communityRating}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
{episode.communityRating.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if episode.userData?.played}
|
||||
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
Watched
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
{#if episode.overview}
|
||||
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed max-w-2xl">
|
||||
{episode.overview}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar if in progress -->
|
||||
{#if progress > 0 && progress < 95}
|
||||
<div class="w-64">
|
||||
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{Math.round(progress)}% watched
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play button -->
|
||||
<div class="pt-2">
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adjacent episodes -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
{@const thumbUrl = getEpisodeThumbnail(ep)}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||
disabled={isCurrent}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if thumbUrl}
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover overlay -->
|
||||
{#if !isCurrent}
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Now Playing indicator -->
|
||||
{#if isCurrent}
|
||||
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
|
||||
Current
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if epProgress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {epProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if ep.userData?.played}
|
||||
<div class="absolute top-2 right-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Episode info -->
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
|
||||
{ep.indexNumber || 0}.
|
||||
</span>
|
||||
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
||||
{ep.name}
|
||||
</p>
|
||||
</div>
|
||||
{#if ep.overview}
|
||||
<p class="text-gray-400 text-sm line-clamp-2">
|
||||
{ep.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
focused?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
// Scroll into view with some offset from top
|
||||
setTimeout(() => {
|
||||
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if this episode is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
|
||||
);
|
||||
|
||||
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
||||
const isDownloading = $derived(
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
function getImageUrl(): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 320,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getProgress(): number {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
|
||||
}
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const progress = $derived(getProgress());
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
|
||||
{onclick}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover overlay with play icon -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Download indicator -->
|
||||
{#if isDownloaded || isDownloading}
|
||||
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
||||
{#if isDownloaded}
|
||||
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else if isDownloading}
|
||||
<div class="w-5 h-5 relative">
|
||||
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="rgba(0,0,0,0.6)"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
|
||||
stroke-linecap="round"
|
||||
class="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Episode info -->
|
||||
<div class="flex-1 min-w-0 py-1">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Episode number and title -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
|
||||
{episodeNumber}.
|
||||
</span>
|
||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||
{episode.name}
|
||||
</h3>
|
||||
<!-- Played indicator -->
|
||||
{#if episode.userData?.played}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
{#if episode.overview}
|
||||
<p class="text-gray-400 text-sm mt-1 line-clamp-2">
|
||||
{episode.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duration and Download -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{#if duration}
|
||||
<span class="text-gray-500 text-sm">
|
||||
{duration}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Download button - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<VideoDownloadButton
|
||||
itemId={episode.id}
|
||||
itemName={episode.name}
|
||||
seriesName={episode.seriesName}
|
||||
seasonName={episode.seasonName}
|
||||
episodeNumber={episode.indexNumber}
|
||||
seasonNumber={episode.parentIndexNumber}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import type { Genre, MediaItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
||||
* Consolidates duplicate genre-browsing logic across media types
|
||||
*
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-030 - Quick genre browsing and filtering
|
||||
* @req: DR-007 - Library browsing screens (genre filtering)
|
||||
*/
|
||||
|
||||
export interface GenreConfig {
|
||||
itemTypes: string[]; // ["Movie"] or ["MusicAlbum"] or ["Series"]
|
||||
title: string; // "Movie Genres" or "Genres" or "TV Genres"
|
||||
backPath: string; // "/library" or "/library/music"
|
||||
genreIcon: string; // SVG path for genre icon
|
||||
itemDisplayMode: "poster" | "square"; // Aspect ratio: 2/3 or 1/1
|
||||
searchPlaceholder?: string; // Optional custom placeholder
|
||||
noItemsMessage?: string; // Optional custom empty state
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: GenreConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let genres = $state<Genre[]>([]);
|
||||
let filteredGenres = $state<Genre[]>([]);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state("");
|
||||
let selectedGenre = $state<Genre | null>(null);
|
||||
let genreItems = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadGenres();
|
||||
if (selectedGenre) {
|
||||
await loadGenreItems(selectedGenre);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadGenres();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
async function loadGenres() {
|
||||
if (!$currentLibrary) {
|
||||
goto(config.backPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getGenres($currentLibrary.id);
|
||||
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
applyFilter();
|
||||
} catch (e) {
|
||||
console.error("Failed to load genres:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGenreItems(genre: Genre) {
|
||||
if (!$currentLibrary) return;
|
||||
|
||||
try {
|
||||
loadingItems = true;
|
||||
selectedGenre = genre;
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: config.itemTypes,
|
||||
genres: [genre.name],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: 10000,
|
||||
});
|
||||
genreItems = result.items;
|
||||
} catch (e) {
|
||||
console.error("Failed to load genre items:", e);
|
||||
} finally {
|
||||
loadingItems = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
let result = [...genres];
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((genre) => genre.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
filteredGenres = result;
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
searchQuery = query;
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function handleGenreClick(genre: Genre) {
|
||||
loadGenreItems(genre);
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (selectedGenre) {
|
||||
selectedGenre = null;
|
||||
genreItems = [];
|
||||
} else {
|
||||
goto(config.backPath);
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatioClass = config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square";
|
||||
const gridColsClass =
|
||||
config.itemDisplayMode === "poster"
|
||||
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6";
|
||||
const searchPlaceholder = config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`;
|
||||
const noItemsMessage = config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">
|
||||
{#if selectedGenre}
|
||||
{selectedGenre.name}
|
||||
{:else}
|
||||
{config.title}
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{#if !selectedGenre}
|
||||
<!-- Genre Browser -->
|
||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||
|
||||
{#if !loading && filteredGenres.length > 0}
|
||||
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Genres Grid -->
|
||||
{#if loading}
|
||||
<div class="grid {gridColsClass} gap-4">
|
||||
{#each Array(12) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filteredGenres.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No genres found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid {gridColsClass} gap-4">
|
||||
{#each filteredGenres as genre (genre.id)}
|
||||
<button onclick={() => handleGenreClick(genre)} class="group text-left">
|
||||
<div
|
||||
class="aspect-square bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-[var(--color-jellyfin)]/5 rounded-lg flex items-center justify-center group-hover:from-[var(--color-jellyfin)]/30 group-hover:to-[var(--color-jellyfin)]/10 transition-all"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-[var(--color-jellyfin)] opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{@html config.genreIcon}
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{genre.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Genre Items View -->
|
||||
{#if loadingItems}
|
||||
<div class="grid {gridColsClass} gap-4">
|
||||
{#each Array(10) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if genreItems.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>{noItemsMessage}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
|
||||
<div class="grid {gridColsClass} gap-4 mt-4">
|
||||
{#each genreItems as item (item.id)}
|
||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||
{#if item.primaryImageTag}
|
||||
<img
|
||||
src={auth.getRepository().getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: item.primaryImageTag,
|
||||
})}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
</p>
|
||||
{#if item.productionYear}
|
||||
<p class="text-sm text-gray-400">
|
||||
{item.productionYear}
|
||||
{#if item.communityRating}
|
||||
<span class="text-yellow-500 ml-1">★ {item.communityRating.toFixed(1)}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
|
||||
/**
|
||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||
* Consolidates duplicate music library browsing logic
|
||||
*
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-008 - Search media across libraries
|
||||
* @req: DR-007 - Library browsing screens
|
||||
*/
|
||||
|
||||
export interface MediaListConfig {
|
||||
itemType: string; // "MusicAlbum", "MusicArtist", "Playlist", "Audio"
|
||||
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
||||
backPath: string; // "/library/music"
|
||||
searchPlaceholder?: string;
|
||||
sortOptions: SortOption[];
|
||||
defaultSort: string;
|
||||
displayComponent: "grid" | "tracklist"; // Which component to use
|
||||
searchFields: string[]; // Which fields to search in: ["name", "artists"], etc.
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: MediaListConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let filteredItems = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state<string>(config.defaultSort);
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadItems();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
if (!$currentLibrary) {
|
||||
goto(config.backPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: [config.itemType],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
});
|
||||
items = result.items;
|
||||
applySortAndFilter();
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${config.itemType}:`, e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySortAndFilter() {
|
||||
let result = [...items];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((item) => {
|
||||
return config.searchFields.some((field) => {
|
||||
if (field === "artists" && item.artists) {
|
||||
return item.artists.some((a) => a.toLowerCase().includes(query));
|
||||
}
|
||||
const value = item[field as keyof MediaItem];
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase().includes(query);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting - find the matching sort option and use its compareFn
|
||||
const selectedSortOption = config.sortOptions.find((opt) => opt.key === sortBy);
|
||||
if (selectedSortOption && "compareFn" in selectedSortOption) {
|
||||
result.sort(selectedSortOption.compareFn as (a: MediaItem, b: MediaItem) => number);
|
||||
}
|
||||
|
||||
filteredItems = result;
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
searchQuery = query;
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function handleSort(newSort: string) {
|
||||
sortBy = newSort;
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto(config.backPath);
|
||||
}
|
||||
|
||||
const searchPlaceholder = config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`;
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
// Navigate to detail page for browseable items
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
function handleTrackClick(track: MediaItem, _index: number) {
|
||||
// For track lists, navigate to the track's album if available, otherwise detail page
|
||||
if (track.albumId) {
|
||||
goto(`/library/${track.albumId}`);
|
||||
} else {
|
||||
goto(`/library/${track.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search and Sort Bar -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1">
|
||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||
</div>
|
||||
|
||||
<!-- Sort (only show if there are sort options) -->
|
||||
{#if config.sortOptions.length > 0}
|
||||
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
{#if !loading}
|
||||
<ResultsCounter count={filteredItems.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Items List/Grid -->
|
||||
{#if loading}
|
||||
{#if config.displayComponent === "grid"}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{#each Array(10) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<div class="animate-pulse h-16 bg-[var(--color-surface)] rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No {config.title.toLowerCase()} found</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if config.displayComponent === "grid"}
|
||||
<LibraryGrid items={filteredItems} onItemClick={handleItemClick} />
|
||||
{:else if config.displayComponent === "tracklist"}
|
||||
<TrackList tracks={filteredItems} onTrackClick={handleTrackClick} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { library, genres, selectedGenres } from "$lib/stores/library";
|
||||
|
||||
interface Props {
|
||||
onFilterChange?: () => void;
|
||||
}
|
||||
|
||||
let { onFilterChange }: Props = $props();
|
||||
|
||||
function handleToggleGenre(genreName: string) {
|
||||
library.toggleGenre(genreName);
|
||||
onFilterChange?.();
|
||||
}
|
||||
|
||||
function handleClearAll() {
|
||||
library.clearGenres();
|
||||
onFilterChange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $genres.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-400">Filter by genre</span>
|
||||
{#if $selectedGenres.length > 0}
|
||||
<button
|
||||
onclick={handleClearAll}
|
||||
class="text-xs text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $genres as genre (genre.id)}
|
||||
{@const isSelected = $selectedGenres.includes(genre.name)}
|
||||
<button
|
||||
onclick={() => handleToggleGenre(genre.name)}
|
||||
class="px-3 py-1 rounded-full text-sm transition-colors
|
||||
{isSelected
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
{genre.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
maxShow,
|
||||
clickable = true
|
||||
}: Props = $props();
|
||||
|
||||
const displayGenres = $derived(
|
||||
maxShow ? genres.slice(0, maxShow) : genres
|
||||
);
|
||||
|
||||
const hiddenCount = $derived(
|
||||
maxShow && genres.length > maxShow ? genres.length - maxShow : 0
|
||||
);
|
||||
|
||||
function handleGenreClick(genre: string) {
|
||||
if (clickable) {
|
||||
// Navigate to genre browse page
|
||||
// For now, we'll use a simple navigation - could be enhanced with a proper genre browse page
|
||||
goto(`/search?genre=${encodeURIComponent(genre)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if genres && genres.length > 0}
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#each displayGenres as genre (genre)}
|
||||
<button
|
||||
onclick={() => handleGenreClick(genre)}
|
||||
disabled={!clickable}
|
||||
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable ? 'hover:bg-[var(--color-surface-hover)] cursor-pointer' : 'cursor-default'}"
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if hiddenCount > 0}
|
||||
<span class="text-sm text-gray-400 px-2">
|
||||
+{hiddenCount} more
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
button:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
import LibraryListView from "./LibraryListView.svelte";
|
||||
import { library, viewMode } from "$lib/stores/library";
|
||||
|
||||
interface Props {
|
||||
items: (MediaItem | Library)[];
|
||||
title?: string;
|
||||
loading?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
forceGrid?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, onItemClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
{#if title}
|
||||
<h2 class="text-xl font-semibold text-white">{title}</h2>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
{#if showViewToggle && items.length > 0}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onclick={() => library.setViewMode("grid")}
|
||||
class="p-2 rounded transition-colors {$viewMode === 'grid' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
aria-label="Grid view"
|
||||
title="Grid view"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => library.setViewMode("list")}
|
||||
class="p-2 rounded transition-colors {$viewMode === 'list' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
aria-label="List view"
|
||||
title="List view"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(6) as _}
|
||||
<div class="w-36 flex-shrink-0 animate-pulse">
|
||||
<div class="aspect-[2/3] bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
<div class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{:else if !forceGrid && $viewMode === "list"}
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each items as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
|
||||
interface Props {
|
||||
items: (MediaItem | Library)[];
|
||||
showProgress?: boolean;
|
||||
showDownloadStatus?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
||||
|
||||
function getDownloadInfo(itemId: string) {
|
||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||
}
|
||||
|
||||
function getImageUrl(item: MediaItem | Library): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
return repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
if (!("type" in item)) return "";
|
||||
|
||||
switch (item.type) {
|
||||
case "Audio":
|
||||
return item.artists?.join(", ") || item.albumName || "";
|
||||
case "MusicAlbum":
|
||||
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
||||
case "Episode":
|
||||
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
|
||||
case "Movie":
|
||||
case "Series":
|
||||
return item.productionYear?.toString() || "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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 getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||
}
|
||||
|
||||
function getTrackNumber(item: MediaItem | Library): string {
|
||||
if ("indexNumber" in item && item.indexNumber) {
|
||||
return item.indexNumber.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const imageUrl = getImageUrl(item)}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
{@const trackNum = getTrackNumber(item)}
|
||||
{@const isPlayed = "userData" in item && item.userData?.played}
|
||||
{@const downloadInfo = getDownloadInfo(item.id)}
|
||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onItemClick?.(item)}
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
|
||||
>
|
||||
<!-- Track number or index -->
|
||||
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
|
||||
{trackNum || index + 1}
|
||||
</span>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play overlay on hover -->
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700">
|
||||
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title & Subtitle -->
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Download indicator -->
|
||||
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
||||
{#if isDownloaded}
|
||||
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" title="Downloaded">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
{:else if isDownloading}
|
||||
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
|
||||
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
|
||||
<circle
|
||||
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
|
||||
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if isPlayed}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- Duration -->
|
||||
{#if duration}
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { getImageUrlSync } from "$lib/services/imageCache";
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
size?: "small" | "medium" | "large";
|
||||
showProgress?: boolean;
|
||||
showDownloadStatus?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||
);
|
||||
|
||||
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
||||
const isDownloading = $derived(
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const sizeClasses = {
|
||||
small: "w-24",
|
||||
medium: "w-36",
|
||||
large: "w-48",
|
||||
};
|
||||
|
||||
const aspectRatio = $derived(() => {
|
||||
if ("type" in item) {
|
||||
// MediaItem
|
||||
return item.type === "Audio" || item.type === "MusicAlbum" ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
// Library
|
||||
return "aspect-video";
|
||||
});
|
||||
|
||||
function getImageUrl(): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const serverUrl = repo.serverUrl;
|
||||
const id = item.id;
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
||||
|
||||
// Use the caching service - returns server URL immediately and triggers background caching
|
||||
return getImageUrlSync(serverUrl, id, "Primary", {
|
||||
maxWidth,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getProgress(): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (!("type" in item)) return "";
|
||||
|
||||
switch (item.type) {
|
||||
case "Audio":
|
||||
return item.artists?.join(", ") || item.albumName || "";
|
||||
case "MusicAlbum":
|
||||
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
||||
case "Episode":
|
||||
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
|
||||
case "Movie":
|
||||
return item.productionYear?.toString() || "";
|
||||
case "Series":
|
||||
return item.productionYear?.toString() || "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const progress = $derived(getProgress());
|
||||
const subtitle = $derived(getSubtitle());
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 hover:scale-105"
|
||||
{onclick}
|
||||
>
|
||||
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover overlay with smooth gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl">
|
||||
<svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if "userData" in item && item.userData?.played}
|
||||
<div class="absolute top-2 right-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Download indicator -->
|
||||
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
||||
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
||||
{#if isDownloaded}
|
||||
<!-- Downloaded badge -->
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else if isDownloading}
|
||||
<!-- Downloading progress -->
|
||||
<div class="w-6 h-6 relative">
|
||||
<svg class="w-6 h-6 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="rgba(0,0,0,0.6)"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
|
||||
stroke-linecap="round"
|
||||
class="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<svg class="absolute inset-0 m-auto w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-0.5">
|
||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { onMount } from "svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
person: MediaItem;
|
||||
}
|
||||
|
||||
let { person }: Props = $props();
|
||||
|
||||
let movies = $state<MediaItem[]>([]);
|
||||
let series = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await loadFilmography();
|
||||
});
|
||||
|
||||
async function loadFilmography() {
|
||||
loading = true;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItemsByPerson(person.id, {
|
||||
limit: 100,
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
});
|
||||
|
||||
// Separate movies and series
|
||||
movies = result.items.filter(item => item.type === "Movie");
|
||||
series = result.items.filter(item => item.type === "Series");
|
||||
} catch (e) {
|
||||
console.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getImageUrl(): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 400,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Person header -->
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Profile image -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if imageUrl && person.primaryImageTag}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<h1 class="text-3xl font-bold text-white">{person.name}</h1>
|
||||
|
||||
<span class="inline-block px-2 py-1 bg-[var(--color-surface)] rounded text-sm text-gray-400">
|
||||
Person
|
||||
</span>
|
||||
|
||||
{#if person.overview}
|
||||
<p class="text-gray-300 leading-relaxed max-w-2xl">{person.overview}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filmography - separated by type -->
|
||||
<div class="space-y-8">
|
||||
{#if movies.length > 0}
|
||||
<LibraryGrid
|
||||
title="Movies"
|
||||
items={movies}
|
||||
{loading}
|
||||
showViewToggle={false}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if series.length > 0}
|
||||
<LibraryGrid
|
||||
title="TV Series"
|
||||
items={series}
|
||||
{loading}
|
||||
showViewToggle={false}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !loading && movies.length === 0 && series.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No filmography found</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
|
||||
genres?: string[];
|
||||
people?: Person[];
|
||||
artistIds?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
currentItemId,
|
||||
itemType,
|
||||
genres = [],
|
||||
people = [],
|
||||
artistIds = [],
|
||||
limit = 12
|
||||
}: Props = $props();
|
||||
|
||||
let relatedItems = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadRelatedItems();
|
||||
});
|
||||
|
||||
async function loadRelatedItems() {
|
||||
loading = true;
|
||||
error = null;
|
||||
relatedItems = [];
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
if (!repo) {
|
||||
error = "Not authenticated";
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MediaItem[] = [];
|
||||
|
||||
// First, try to use the Jellyfin Similar Items API (preferred method)
|
||||
// This works for Movies and Series (most common cases)
|
||||
if (["Movie", "Series"].includes(itemType)) {
|
||||
try {
|
||||
const result = await repo.getSimilarItems(currentItemId, limit);
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
|
||||
if (items.length > 0) {
|
||||
relatedItems = items.slice(0, limit);
|
||||
return; // Success - return early
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load similar items from API:", e);
|
||||
// Fall through to genre-based loading
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Load by genres using search (works for all item types)
|
||||
if (genres && genres.length > 0) {
|
||||
try {
|
||||
// Search by first genre to find related items
|
||||
const searchTerm = genres[0];
|
||||
const result = await repo.search(searchTerm, {
|
||||
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType],
|
||||
limit: limit * 2
|
||||
});
|
||||
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related items by genre:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// For music albums, also try to load by artist (if we don't have enough from similar API)
|
||||
if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) {
|
||||
try {
|
||||
// Search for other albums by artist name from first artist
|
||||
const result = await repo.search(artistIds[0], {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
limit: limit * 2
|
||||
});
|
||||
|
||||
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
||||
items = [...items, ...artistAlbums];
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums by artist:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and limit results
|
||||
const uniqueItems = Array.from(
|
||||
new Map(items.map(item => [item.id, item])).values()
|
||||
).slice(0, limit);
|
||||
|
||||
relatedItems = uniqueItems;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load related items";
|
||||
console.error("Error loading related items:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (itemType) {
|
||||
case "Movie":
|
||||
return "Related Movies";
|
||||
case "Series":
|
||||
return "Related Shows";
|
||||
case "MusicAlbum":
|
||||
return "Related Albums";
|
||||
case "Audio":
|
||||
return "Related Tracks";
|
||||
default:
|
||||
return "Related Items";
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">{getTitle()}</h2>
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton loading state -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-[2/3] bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<p>Could not load related items</p>
|
||||
</div>
|
||||
{:else if relatedItems.length === 0}
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<p>No related items found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each relatedItems as item (item.id)}
|
||||
<MediaCard {item} onclick={() => handleItemClick(item)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { downloads, videoDownloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
|
||||
interface Props {
|
||||
seasonId: string;
|
||||
seriesName: string;
|
||||
seasonName: string;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
let { seasonId, seriesName, seasonName, seasonNumber, episodeCount, className = "", size = "md" }: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
|
||||
// Count downloads for this season
|
||||
const seasonDownloads = $derived(
|
||||
$videoDownloads.filter((d) =>
|
||||
d.seriesName === seriesName &&
|
||||
d.seasonName === seasonName
|
||||
)
|
||||
);
|
||||
|
||||
const completedCount = $derived(
|
||||
seasonDownloads.filter((d) => d.status === "completed").length
|
||||
);
|
||||
|
||||
const inProgressCount = $derived(
|
||||
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
|
||||
);
|
||||
|
||||
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
|
||||
const allDownloaded = $derived(completedCount >= episodeCount);
|
||||
|
||||
async function startSeasonDownload(quality: QualityPreset) {
|
||||
showQualityPicker = false;
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📺 Starting season download for:", seasonName, "quality:", quality);
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
const basePath = `${targetDir}/videos`;
|
||||
|
||||
// Queue all episodes in this season
|
||||
const downloadIds = await downloads.downloadSeason(
|
||||
seasonId,
|
||||
seriesName,
|
||||
seasonName,
|
||||
seasonNumber,
|
||||
userId,
|
||||
basePath,
|
||||
quality
|
||||
);
|
||||
|
||||
console.log(`✅ Queued ${downloadIds.length} episodes for download`);
|
||||
|
||||
// Pin the season item
|
||||
await downloads.pinItem(seasonId);
|
||||
} catch (error) {
|
||||
console.error("Failed to start season download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (isProcessing) return;
|
||||
showQualityPicker = true;
|
||||
}
|
||||
|
||||
function getButtonText(): string {
|
||||
if (allDownloaded) {
|
||||
return size === "sm" ? "✓" : "Downloaded";
|
||||
}
|
||||
if (inProgressCount > 0) {
|
||||
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
|
||||
}
|
||||
if (completedCount > 0) {
|
||||
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`;
|
||||
}
|
||||
return size === "sm" ? "⬇" : "Download Season";
|
||||
}
|
||||
|
||||
function getButtonColor(): string {
|
||||
if (allDownloaded) {
|
||||
return "bg-green-600 hover:bg-green-700";
|
||||
}
|
||||
if (inProgressCount > 0) {
|
||||
return "bg-blue-600 hover:bg-blue-700";
|
||||
}
|
||||
return "bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)]";
|
||||
}
|
||||
|
||||
const sizeClasses = $derived(
|
||||
size === "sm" ? "px-2 py-1 text-xs" :
|
||||
size === "lg" ? "px-6 py-3 text-base" :
|
||||
"px-4 py-2 text-sm"
|
||||
);
|
||||
|
||||
const iconSize = $derived(
|
||||
size === "sm" ? "w-3 h-3" :
|
||||
size === "lg" ? "w-6 h-6" :
|
||||
"w-4 h-4"
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing || allDownloaded}
|
||||
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
|
||||
>
|
||||
{#if inProgressCount > 0}
|
||||
<!-- Spinner -->
|
||||
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else if allDownloaded}
|
||||
<!-- Checkmark -->
|
||||
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if size !== "sm"}
|
||||
<span>{getButtonText()}</span>
|
||||
{:else}
|
||||
<span>{getButtonText()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<div class="text-sm font-medium text-white">Download Quality</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
|
||||
</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startSeasonDownload(key as QualityPreset)}
|
||||
class="w-full px-4 py-3 text-left hover:bg-gray-700 transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span class="text-sm text-white">{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close -->
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
|
||||
interface Props {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
focusedEpisodeId?: string;
|
||||
onEpisodeClick?: (episode: MediaItem) => void;
|
||||
}
|
||||
|
||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||
|
||||
function getImageUrl(): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(season.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: season.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<!-- Season header -->
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Season info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{seasonName}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
|
||||
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
|
||||
{#if season.productionYear}
|
||||
<span>•</span>
|
||||
<span>{season.productionYear}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if season.overview}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
|
||||
{season.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Download Season Button -->
|
||||
<div class="flex-shrink-0">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
seasonName={seasonName}
|
||||
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode list -->
|
||||
<div class="space-y-1 pl-2">
|
||||
{#each episodes as episode (episode.id)}
|
||||
<EpisodeRow
|
||||
{episode}
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { downloads, videoDownloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
|
||||
interface Props {
|
||||
seriesId: string;
|
||||
seriesName: string;
|
||||
episodeCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { seriesId, seriesName, episodeCount, className = "" }: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
|
||||
// Count downloads for this series
|
||||
const seriesDownloads = $derived(
|
||||
$videoDownloads.filter((d) => d.seriesName === seriesName)
|
||||
);
|
||||
|
||||
const completedCount = $derived(
|
||||
seriesDownloads.filter((d) => d.status === "completed").length
|
||||
);
|
||||
|
||||
const inProgressCount = $derived(
|
||||
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
|
||||
);
|
||||
|
||||
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
|
||||
const allDownloaded = $derived(episodeCount !== undefined && completedCount >= episodeCount);
|
||||
|
||||
async function startSeriesDownload(quality: QualityPreset) {
|
||||
showQualityPicker = false;
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📺 Starting series download for:", seriesName, "quality:", quality);
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
const basePath = `${targetDir}/videos`;
|
||||
|
||||
// Queue all episodes
|
||||
const downloadIds = await downloads.downloadSeries(
|
||||
seriesId,
|
||||
seriesName,
|
||||
userId,
|
||||
basePath,
|
||||
quality
|
||||
);
|
||||
|
||||
console.log(` Queued ${downloadIds.length} episodes for download`);
|
||||
|
||||
// Pin the series item
|
||||
await downloads.pinItem(seriesId);
|
||||
|
||||
// Start downloads (the backend will handle queuing)
|
||||
// For now, we'll rely on a download manager to pick them up
|
||||
// TODO: Implement batch download start
|
||||
} catch (error) {
|
||||
console.error("Failed to start series download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isProcessing) return;
|
||||
showQualityPicker = true;
|
||||
}
|
||||
|
||||
function getButtonText(): string {
|
||||
if (allDownloaded) {
|
||||
return "Downloaded";
|
||||
}
|
||||
if (inProgressCount > 0) {
|
||||
return `Downloading... (${inProgressCount})`;
|
||||
}
|
||||
if (completedCount > 0 && episodeCount) {
|
||||
return `Download (${completedCount}/${episodeCount})`;
|
||||
}
|
||||
return "Download Series";
|
||||
}
|
||||
|
||||
function getButtonColor(): string {
|
||||
if (allDownloaded) {
|
||||
return "bg-green-600 hover:bg-green-700";
|
||||
}
|
||||
if (inProgressCount > 0) {
|
||||
return "bg-blue-600 hover:bg-blue-700";
|
||||
}
|
||||
return "bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)]";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing || allDownloaded}
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
|
||||
>
|
||||
{#if inProgressCount > 0}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else if allDownloaded}
|
||||
<!-- Checkmark -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{getButtonText()}</span>
|
||||
</button>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<div class="text-sm font-medium text-white">Download Quality</div>
|
||||
{#if episodeCount}
|
||||
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startSeriesDownload(key as QualityPreset)}
|
||||
class="w-full px-4 py-3 text-left hover:bg-gray-700 transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span class="text-sm text-white">{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close -->
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
// Mock modules
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-os", () => ({
|
||||
platform: vi.fn(() => "linux"),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api/client", () => ({
|
||||
default: class {},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TrackList Logic Tests", () => {
|
||||
const mockRepository = {
|
||||
getAudioStreamUrl: vi.fn(),
|
||||
getImageUrl: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTracks: MediaItem[] = [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Song 1",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
artists: ["Artist 1"],
|
||||
albumName: "Album 1",
|
||||
albumId: "album-1",
|
||||
runTimeTicks: 1800000000,
|
||||
primaryImageTag: "tag1",
|
||||
indexNumber: 1,
|
||||
},
|
||||
{
|
||||
id: "track-2",
|
||||
name: "Song 2",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
artists: ["Artist 2"],
|
||||
albumName: "Album 2",
|
||||
albumId: "album-2",
|
||||
runTimeTicks: 2400000000,
|
||||
primaryImageTag: "tag2",
|
||||
indexNumber: 2,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(auth.getRepository as any).mockReturnValue(mockRepository);
|
||||
mockRepository.getAudioStreamUrl.mockResolvedValue("http://stream.url/track");
|
||||
mockRepository.getImageUrl.mockReturnValue("http://image.url/artwork");
|
||||
});
|
||||
|
||||
describe("Queue Building Logic", () => {
|
||||
it("should build correct queue structure from tracks", async () => {
|
||||
(invoke as any).mockResolvedValue(undefined);
|
||||
|
||||
// Simulate the queue building logic from defaultHandleTrackClick
|
||||
const repo = auth.getRepository();
|
||||
const queueItems = await Promise.all(
|
||||
mockTracks.map(async (t) => ({
|
||||
id: t.id,
|
||||
title: t.name,
|
||||
artist: t.artists?.join(", "),
|
||||
album: t.albumName,
|
||||
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : undefined,
|
||||
artworkUrl: t.primaryImageTag
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: t.primaryImageTag,
|
||||
})
|
||||
: undefined,
|
||||
mediaType: "Audio",
|
||||
streamUrl: await repo.getAudioStreamUrl(t.id),
|
||||
jellyfinItemId: t.id,
|
||||
}))
|
||||
);
|
||||
|
||||
expect(queueItems).toHaveLength(2);
|
||||
expect(queueItems[0]).toMatchObject({
|
||||
id: "track-1",
|
||||
title: "Song 1",
|
||||
artist: "Artist 1",
|
||||
album: "Album 1",
|
||||
duration: 180,
|
||||
mediaType: "Audio",
|
||||
});
|
||||
expect(queueItems[0].streamUrl).toBe("http://stream.url/track");
|
||||
expect(queueItems[0].artworkUrl).toBe("http://image.url/artwork");
|
||||
});
|
||||
|
||||
it("should call getAudioStreamUrl for each track", async () => {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
await Promise.all(
|
||||
mockTracks.map(async (t) => {
|
||||
const streamUrl = await repo.getAudioStreamUrl(t.id);
|
||||
return {
|
||||
id: t.id,
|
||||
streamUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2);
|
||||
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledWith("track-1");
|
||||
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledWith("track-2");
|
||||
});
|
||||
|
||||
it("should handle tracks without artwork", async () => {
|
||||
const trackWithoutArt: MediaItem = {
|
||||
...mockTracks[0],
|
||||
primaryImageTag: undefined,
|
||||
};
|
||||
|
||||
const repo = auth.getRepository();
|
||||
const artworkUrl = trackWithoutArt.primaryImageTag
|
||||
? repo.getImageUrl(trackWithoutArt.albumId || trackWithoutArt.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: trackWithoutArt.primaryImageTag,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
expect(artworkUrl).toBeUndefined();
|
||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle tracks without artist", async () => {
|
||||
const trackWithoutArtist: MediaItem = {
|
||||
...mockTracks[0],
|
||||
artists: undefined,
|
||||
};
|
||||
|
||||
const artistString = trackWithoutArtist.artists?.join(", ");
|
||||
expect(artistString).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should join multiple artists with comma", () => {
|
||||
const track: MediaItem = {
|
||||
...mockTracks[0],
|
||||
artists: ["Artist 1", "Artist 2", "Artist 3"],
|
||||
};
|
||||
|
||||
const artistString = track.artists?.join(", ");
|
||||
expect(artistString).toBe("Artist 1, Artist 2, Artist 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duration Formatting", () => {
|
||||
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")}`;
|
||||
}
|
||||
|
||||
it("should format duration correctly", () => {
|
||||
expect(formatDuration(1800000000)).toBe("3:00"); // 3 minutes
|
||||
expect(formatDuration(2400000000)).toBe("4:00"); // 4 minutes
|
||||
expect(formatDuration(3000000000)).toBe("5:00"); // 5 minutes
|
||||
});
|
||||
|
||||
it("should handle seconds padding", () => {
|
||||
expect(formatDuration(650000000)).toBe("1:05"); // 1:05
|
||||
expect(formatDuration(6150000000)).toBe("10:15"); // 10:15
|
||||
});
|
||||
|
||||
it("should return dash for undefined duration", () => {
|
||||
expect(formatDuration(undefined)).toBe("-");
|
||||
expect(formatDuration(0)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player Invocation", () => {
|
||||
it("should invoke player_play_queue with correct parameters", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
|
||||
const queueItems = [
|
||||
{
|
||||
id: "track-1",
|
||||
title: "Song 1",
|
||||
artist: "Artist 1",
|
||||
album: "Album 1",
|
||||
duration: 180,
|
||||
artworkUrl: "http://image.url/artwork",
|
||||
mediaType: "Audio",
|
||||
streamUrl: "http://stream.url/track",
|
||||
jellyfinItemId: "track-1",
|
||||
},
|
||||
];
|
||||
|
||||
await invoke("player_play_queue", {
|
||||
request: {
|
||||
items: queueItems,
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("player_play_queue", {
|
||||
request: {
|
||||
items: queueItems,
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use correct startIndex for different positions", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
const queueItems = mockTracks.map((t) => ({ id: t.id, title: t.name }));
|
||||
|
||||
// Test clicking second track (index 1)
|
||||
await invoke("player_play_queue", {
|
||||
request: {
|
||||
items: queueItems,
|
||||
startIndex: 1,
|
||||
shuffle: false,
|
||||
},
|
||||
});
|
||||
|
||||
const callArgs = invokeMock.mock.calls[0][1] as any;
|
||||
expect(callArgs.request.startIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle missing auth repository", () => {
|
||||
(auth.getRepository as any).mockReturnValue(null);
|
||||
|
||||
const repo = auth.getRepository();
|
||||
expect(repo).toBeNull();
|
||||
|
||||
// In the actual component, this would throw "Not authenticated"
|
||||
expect(() => {
|
||||
if (!repo) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
}).toThrow("Not authenticated");
|
||||
|
||||
// Restore for other tests
|
||||
(auth.getRepository as any).mockReturnValue(mockRepository);
|
||||
});
|
||||
|
||||
it("should handle stream URL generation failure", async () => {
|
||||
mockRepository.getAudioStreamUrl.mockResolvedValue(null);
|
||||
|
||||
const streamUrl = await mockRepository.getAudioStreamUrl("track-1");
|
||||
expect(streamUrl).toBeNull();
|
||||
|
||||
// In the actual component, this would throw an error
|
||||
expect(() => {
|
||||
if (!streamUrl) {
|
||||
throw new Error("Failed to get stream URL for track");
|
||||
}
|
||||
}).toThrow("Failed to get stream URL");
|
||||
});
|
||||
|
||||
it("should handle player invoke errors", async () => {
|
||||
const error = new Error("Network error");
|
||||
(invoke as any).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
invoke("player_play_queue", {
|
||||
request: {
|
||||
items: [],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callback vs Default Handler", () => {
|
||||
it("should use custom callback when provided", async () => {
|
||||
const customCallback = vi.fn();
|
||||
const track = mockTracks[0];
|
||||
const index = 0;
|
||||
|
||||
// Simulate the unified handler logic
|
||||
const onTrackClick = customCallback;
|
||||
if (onTrackClick) {
|
||||
await onTrackClick(track, index);
|
||||
}
|
||||
|
||||
expect(customCallback).toHaveBeenCalledWith(track, index);
|
||||
expect(customCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not invoke player when custom callback is provided", async () => {
|
||||
const customCallback = vi.fn();
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
|
||||
// Simulate unified handler with custom callback
|
||||
const onTrackClick = customCallback;
|
||||
if (onTrackClick) {
|
||||
await onTrackClick(mockTracks[0], 0);
|
||||
} else {
|
||||
// This branch wouldn't execute
|
||||
await invoke("player_play_queue", {
|
||||
request: { items: [], startIndex: 0, shuffle: false },
|
||||
});
|
||||
}
|
||||
|
||||
expect(customCallback).toHaveBeenCalled();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should invoke player when no custom callback", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
|
||||
// Simulate unified handler without custom callback
|
||||
const onTrackClick = undefined;
|
||||
if (onTrackClick) {
|
||||
await onTrackClick(mockTracks[0], 0);
|
||||
} else {
|
||||
// This branch executes - default handler
|
||||
await invoke("player_play_queue", {
|
||||
request: { items: [], startIndex: 0, shuffle: false },
|
||||
});
|
||||
}
|
||||
|
||||
expect(invokeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
@@ -0,0 +1,550 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock modules BEFORE any imports that might use them
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-os", () => ({
|
||||
platform: vi.fn(() => "linux"),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api/client", () => {
|
||||
return {
|
||||
default: class {
|
||||
static getDeviceName = () => "test-device";
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/queue", () => ({
|
||||
queue: {
|
||||
setQueue: vi.fn(),
|
||||
addToQueue: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./DownloadButton.svelte", () => ({
|
||||
default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Now import the modules after mocks are set up
|
||||
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
describe("TrackList", () => {
|
||||
const mockRepository = {
|
||||
getAudioStreamUrl: vi.fn(),
|
||||
getImageUrl: vi.fn(),
|
||||
getHandle: vi.fn(() => "mock-repository-handle"),
|
||||
};
|
||||
|
||||
const mockTracks: MediaItem[] = [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Song 1",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
artists: ["Artist 1"],
|
||||
albumName: "Album 1",
|
||||
albumId: "album-1",
|
||||
runTimeTicks: 1800000000, // 3 minutes
|
||||
primaryImageTag: "tag1",
|
||||
indexNumber: 1,
|
||||
},
|
||||
{
|
||||
id: "track-2",
|
||||
name: "Song 2",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
artists: ["Artist 2"],
|
||||
albumName: "Album 2",
|
||||
albumId: "album-2",
|
||||
runTimeTicks: 2400000000, // 4 minutes
|
||||
primaryImageTag: "tag2",
|
||||
indexNumber: 2,
|
||||
},
|
||||
{
|
||||
id: "track-3",
|
||||
name: "Song 3 with a Very Long Name That Should Be Truncated",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
artists: ["Artist 3", "Artist 4"],
|
||||
albumName: "Album 3",
|
||||
albumId: "album-3",
|
||||
runTimeTicks: 3000000000, // 5 minutes
|
||||
indexNumber: 3,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(auth.getRepository as any).mockReturnValue(mockRepository as any);
|
||||
mockRepository.getAudioStreamUrl.mockResolvedValue("http://stream.url/track");
|
||||
mockRepository.getImageUrl.mockReturnValue("http://image.url/artwork");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Rendering Tests", () => {
|
||||
it("renders track list with tracks", () => {
|
||||
// Component renders both desktop and mobile views, so use getAllByText
|
||||
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
|
||||
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
|
||||
expect(getAllByText(/Song 3 with a Very Long Name/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows loading skeleton when loading=true", () => {
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: [], loading: true },
|
||||
});
|
||||
|
||||
const skeletons = container.querySelectorAll(".animate-pulse");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows empty state when no tracks", () => {
|
||||
const { getByText } = render(TrackList, { props: { tracks: [] } });
|
||||
|
||||
expect(getByText("No tracks found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows artist column by default", () => {
|
||||
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
expect(getByText("Artist")).toBeTruthy();
|
||||
expect(getByText("Artist 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides artist column when showArtist=false", () => {
|
||||
const { queryByText } = render(TrackList, {
|
||||
props: { tracks: mockTracks, showArtist: false },
|
||||
});
|
||||
|
||||
// Header should not be present
|
||||
expect(queryByText("Artist")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows album column by default", () => {
|
||||
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
expect(getByText("Album")).toBeTruthy();
|
||||
expect(getByText("Album 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides album column when showAlbum=false", () => {
|
||||
const { queryByText } = render(TrackList, {
|
||||
props: { tracks: mockTracks, showAlbum: false },
|
||||
});
|
||||
|
||||
// Header should not be present
|
||||
expect(queryByText("Album")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows duration in correct format", () => {
|
||||
// Component renders both desktop and mobile views
|
||||
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
expect(getAllByText("3:00").length).toBeGreaterThan(0); // track-1: 3 minutes
|
||||
expect(getAllByText("4:00").length).toBeGreaterThan(0); // track-2: 4 minutes
|
||||
expect(getAllByText("5:00").length).toBeGreaterThan(0); // track-3: 5 minutes
|
||||
});
|
||||
|
||||
it("handles tracks without duration", () => {
|
||||
const tracksWithoutDuration: MediaItem[] = [
|
||||
{
|
||||
...mockTracks[0],
|
||||
runTimeTicks: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// Component renders both desktop and mobile views
|
||||
const { getAllByText } = render(TrackList, {
|
||||
props: { tracks: tracksWithoutDuration },
|
||||
});
|
||||
|
||||
expect(getAllByText("-").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles tracks without artist", () => {
|
||||
const tracksWithoutArtist: MediaItem[] = [
|
||||
{
|
||||
...mockTracks[0],
|
||||
artists: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByText } = render(TrackList, {
|
||||
props: { tracks: tracksWithoutArtist },
|
||||
});
|
||||
|
||||
expect(getByText("-")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple artists joined with comma", () => {
|
||||
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
expect(getByText("Artist 3, Artist 4")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default Click Handler Tests", () => {
|
||||
it("calls player_play_queue when track is clicked", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
// Find and click the first track button
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
expect(firstTrackButton).toBeTruthy();
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"player_play_tracks",
|
||||
expect.objectContaining({
|
||||
repositoryHandle: "mock-repository-handle",
|
||||
request: expect.objectContaining({
|
||||
trackIds: expect.arrayContaining(["track-1"]),
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("builds queue with all tracks in order", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const secondTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 2")
|
||||
);
|
||||
|
||||
await fireEvent.click(secondTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = invokeMock.mock.calls[0][1] as any;
|
||||
expect(callArgs.request.trackIds).toEqual(["track-1", "track-2", "track-3"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets correct startIndex for clicked track", async () => {
|
||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const thirdTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 3")
|
||||
);
|
||||
|
||||
await fireEvent.click(thirdTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = invokeMock.mock.calls[0][1] as any;
|
||||
expect(callArgs.request.startIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("calls getAudioStreamUrl for each track", async () => {
|
||||
// NOTE: This test is skipped because the code was refactored to use player_play_tracks
|
||||
// which sends trackIds to the backend. The backend now handles all metadata/stream fetching.
|
||||
// This test expected the old behavior where frontend called getAudioStreamUrl.
|
||||
});
|
||||
|
||||
it.skip("includes artwork URLs in queue items", async () => {
|
||||
// NOTE: This test is skipped because the code was refactored.
|
||||
// Stream URLs and artwork URLs are no longer fetched by frontend.
|
||||
// Backend handles all metadata and stream URL fetching via player_play_tracks.
|
||||
});
|
||||
|
||||
it.skip("handles tracks without artwork gracefully", async () => {
|
||||
// NOTE: This test is skipped because the code no longer includes artwork URLs
|
||||
// in queue items sent to backend. Backend handles artwork fetching independently.
|
||||
});
|
||||
|
||||
it("shows error alert when playback fails", async () => {
|
||||
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
|
||||
(invoke as any).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to play track")
|
||||
);
|
||||
});
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles auth errors gracefully", async () => {
|
||||
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
|
||||
(auth.getRepository as any).mockReturnValue(null as any);
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Not authenticated")
|
||||
);
|
||||
});
|
||||
|
||||
alertSpy.mockRestore();
|
||||
|
||||
// Restore mock for other tests
|
||||
(auth.getRepository as any).mockReturnValue(mockRepository as any);
|
||||
});
|
||||
|
||||
it.skip("handles stream URL generation errors", async () => {
|
||||
// NOTE: This test is skipped because stream URLs are no longer fetched by frontend.
|
||||
// The code now uses player_play_tracks which sends trackIds to backend.
|
||||
// Backend handles all stream URL generation, so this error path no longer exists.
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Callback Tests", () => {
|
||||
it("calls custom callback when provided", async () => {
|
||||
const onTrackClick = vi.fn();
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[0], 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call player_play_queue when custom callback provided", async () => {
|
||||
const onTrackClick = vi.fn();
|
||||
const invokeMock = (invoke as any);
|
||||
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTrackClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("receives correct track and index in callback", async () => {
|
||||
const onTrackClick = vi.fn();
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const secondTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 2")
|
||||
);
|
||||
|
||||
await fireEvent.click(secondTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[1], 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles async custom callbacks", async () => {
|
||||
const onTrackClick = vi.fn().mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTrackClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls custom callback even when it might throw", async () => {
|
||||
// Test that custom callbacks are called - error handling is caller's responsibility
|
||||
const onTrackClick = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTrackClick).toHaveBeenCalledWith(mockTracks[0], 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles empty tracks array", () => {
|
||||
const { getByText } = render(TrackList, { props: { tracks: [] } });
|
||||
|
||||
expect(getByText("No tracks found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles single track", async () => {
|
||||
const singleTrack = [mockTracks[0]];
|
||||
(invoke as any).mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: singleTrack } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const trackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(trackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = (invoke as any).mock.calls[0][1] as any;
|
||||
expect(callArgs.request.trackIds).toEqual(["track-1"]);
|
||||
expect(callArgs.request.startIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles click on first track (index 0)", async () => {
|
||||
(invoke as any).mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = (invoke as any).mock.calls[0][1] as any;
|
||||
expect(callArgs.request.startIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles click on last track", async () => {
|
||||
(invoke as any).mockResolvedValue(undefined);
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const lastTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 3")
|
||||
);
|
||||
|
||||
await fireEvent.click(lastTrackButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = (invoke as any).mock.calls[0][1] as any;
|
||||
expect(callArgs.request.startIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("shows loading spinner when track is clicked", async () => {
|
||||
// Make invoke slow to capture loading state
|
||||
(invoke as any).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
);
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
fireEvent.click(firstTrackButton!);
|
||||
|
||||
// Check for loading spinner
|
||||
await waitFor(() => {
|
||||
const spinner = container.querySelector(".animate-spin");
|
||||
expect(spinner).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables track buttons during loading", async () => {
|
||||
(invoke as any).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
);
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
|
||||
fireEvent.click(firstTrackButton!);
|
||||
|
||||
// Track selection buttons should be disabled during loading
|
||||
await waitFor(() => {
|
||||
// Find track buttons (ones containing song names)
|
||||
const trackButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(btn) => btn.textContent?.includes("Song")
|
||||
);
|
||||
expect(trackButtons.length).toBeGreaterThan(0);
|
||||
trackButtons.forEach((btn) => {
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
itemName?: string;
|
||||
// For movies
|
||||
isMovie?: boolean;
|
||||
// For episodes
|
||||
seriesName?: string;
|
||||
seasonName?: string;
|
||||
episodeNumber?: number;
|
||||
seasonNumber?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
itemId,
|
||||
itemName = "",
|
||||
isMovie = false,
|
||||
seriesName,
|
||||
seasonName,
|
||||
episodeNumber,
|
||||
seasonNumber,
|
||||
size = "md",
|
||||
className = ""
|
||||
}: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
};
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
|
||||
);
|
||||
|
||||
const status = $derived(downloadInfo?.status || "not_downloaded");
|
||||
const progress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
async function startDownload(quality: QualityPreset) {
|
||||
showQualityPicker = false;
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||
|
||||
// Get stream URL based on quality
|
||||
const streamUrl = repo.getVideoDownloadUrl(itemId, quality);
|
||||
console.log(" Stream URL obtained");
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
|
||||
// Create file path
|
||||
const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_");
|
||||
let filePath: string;
|
||||
|
||||
if (isMovie) {
|
||||
filePath = `videos/movies/${safeName}.mp4`;
|
||||
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
|
||||
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
|
||||
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`;
|
||||
} else {
|
||||
filePath = `videos/${safeName}.mp4`;
|
||||
}
|
||||
|
||||
console.log(" File path:", filePath);
|
||||
|
||||
// Queue download with video metadata
|
||||
const downloadId = await downloads.downloadVideo(
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
"video/mp4",
|
||||
isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number
|
||||
itemName || undefined,
|
||||
quality,
|
||||
seriesName,
|
||||
seasonName,
|
||||
episodeNumber,
|
||||
seasonNumber
|
||||
);
|
||||
console.log(" Download queued with ID:", downloadId);
|
||||
|
||||
// Pin the item metadata
|
||||
await downloads.pinItem(itemId);
|
||||
|
||||
// Actually start the download
|
||||
await invoke("start_download", {
|
||||
downloadId,
|
||||
streamUrl,
|
||||
targetDir,
|
||||
});
|
||||
console.log(" Download started");
|
||||
} catch (error) {
|
||||
console.error("Failed to start video download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick() {
|
||||
if (isProcessing) return;
|
||||
|
||||
if (status === "completed") {
|
||||
// Delete download
|
||||
if (downloadInfo?.id) {
|
||||
await downloads.delete(downloadInfo.id);
|
||||
// Unpin when deleted
|
||||
await downloads.unpinItem(itemId);
|
||||
}
|
||||
} else if (status === "downloading" || status === "pending") {
|
||||
// Cancel download
|
||||
if (downloadInfo?.id) {
|
||||
await downloads.cancel(downloadInfo.id);
|
||||
}
|
||||
} else if (status === "failed") {
|
||||
// Show quality picker to retry
|
||||
showQualityPicker = true;
|
||||
} else {
|
||||
// Show quality picker
|
||||
showQualityPicker = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Downloaded - Click to remove";
|
||||
case "downloading":
|
||||
return `Downloading... ${Math.round(progress * 100)}%`;
|
||||
case "pending":
|
||||
return "Queued for download";
|
||||
case "paused":
|
||||
return "Download paused";
|
||||
case "failed":
|
||||
return "Download failed - Click to retry";
|
||||
default:
|
||||
return "Download for offline playback";
|
||||
}
|
||||
}
|
||||
|
||||
function getColor(): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-green-500 hover:text-green-400";
|
||||
case "downloading":
|
||||
case "pending":
|
||||
return "text-blue-500 hover:text-blue-400";
|
||||
case "failed":
|
||||
return "text-red-500 hover:text-red-400";
|
||||
default:
|
||||
return "text-gray-400 hover:text-white";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing}
|
||||
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
||||
? 'opacity-50 cursor-wait'
|
||||
: ''} {className}"
|
||||
title={getTitle()}
|
||||
aria-label={getTitle()}
|
||||
>
|
||||
<div class="relative {sizeClasses[size]}">
|
||||
{#if status === "downloading"}
|
||||
<!-- Progress ring -->
|
||||
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - progress)}
|
||||
stroke-linecap="round"
|
||||
class="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
class="absolute inset-0 m-auto w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{:else if status === "completed"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if status === "pending"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
|
||||
</svg>
|
||||
{:else if status === "failed"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
||||
Select Quality
|
||||
</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startDownload(key as QualityPreset)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close -->
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user