First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
// Determine if a route is active
function isActive(path: string): boolean {
const pathname = $page.url.pathname;
if (path === '/') {
// Home is active only when exactly on / or /home, not /library or /search
return pathname === '/' || (pathname.startsWith('/home') && !pathname.startsWith('/library') && !pathname.startsWith('/search'));
}
return pathname.startsWith(path);
}
</script>
<!-- Navigation bar visible on all platforms -->
<nav class="fixed bottom-0 left-0 right-0 bg-[var(--color-surface)] border-t border-gray-800 z-40">
<div class="flex items-center justify-around px-4 py-2">
<!-- Home Button -->
<button
onclick={() => goto('/')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') && !isActive('/library') && !isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Home"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
<span class="text-xs">Home</span>
</button>
<!-- Library Button -->
<button
onclick={() => goto('/library')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" 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-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
</svg>
<span class="text-xs">Library</span>
</button>
<!-- Search Button -->
<button
onclick={() => goto('/search')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Search"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<span class="text-xs">Search</span>
</button>
</div>
</nav>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { toggleFavorite } from "$lib/services/favorites";
import { haptics } from "$lib/utils/haptics";
import { toast } from "$lib/stores/toast";
interface Props {
itemId: string;
isFavorite?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
}
let { itemId, isFavorite = $bindable(false), size = "md", className = "" }: Props = $props();
let isLoading = $state(false);
let isAnimating = $state(false);
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
async function handleToggle() {
if (isLoading) return;
isLoading = true;
isAnimating = true;
try {
const newValue = await toggleFavorite(itemId, isFavorite);
isFavorite = newValue;
// Haptic feedback
if (newValue) {
haptics.success();
toast.show("Added to favorites", "success", 1500);
} else {
haptics.tap();
toast.show("Removed from favorites", "info", 1500);
}
// Reset animation after it completes
setTimeout(() => {
isAnimating = false;
}, 600);
} catch (error) {
console.error("Failed to toggle favorite:", error);
toast.show("Failed to update favorites", "error");
isAnimating = false;
} finally {
isLoading = false;
}
}
// Compute button classes
const buttonClass = $derived.by(() => {
const baseClasses = "p-2 rounded-full transition-all";
const colorClasses = isFavorite ? "text-red-500 hover:text-red-400" : "text-gray-400 hover:text-white";
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
});
// Compute SVG classes
const svgClass = $derived.by(() => {
const sizeClass = sizeClasses[size];
return sizeClass;
});
// Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
const svgStyle = $derived(isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "");
</script>
<button
onclick={handleToggle}
disabled={isLoading}
class={buttonClass}
style={buttonStyle}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{#if isFavorite}
<!-- Filled heart with scale animation -->
<svg
class={svgClass}
style={svgStyle}
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<!-- Outline heart -->
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{/if}
</button>
<style>
@keyframes heart-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@keyframes bounce-once {
0%, 100% {
transform: translateY(0);
}
25% {
transform: translateY(-8px);
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(-4px);
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
children: any;
}
let { children }: Props = $props();
/**
* Portal action - moves the DOM node to document.body
* This escapes any overflow clipping boundaries
*/
function portal(node: HTMLElement) {
const container = document.createElement('div');
document.body.appendChild(container);
container.appendChild(node);
return {
destroy() {
if (container.parentNode) {
document.body.removeChild(container);
}
}
};
}
</script>
<div use:portal>
{@render children()}
</div>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
interface Props {
value?: string;
placeholder?: string;
onSearch?: (query: string) => void;
}
let { value = $bindable(""), placeholder = "Search...", onSearch }: Props = $props();
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
onSearch?.(value);
}, 300);
}
function handleClear() {
value = "";
onSearch?.("");
}
function handleSubmit(e: Event) {
e.preventDefault();
clearTimeout(debounceTimer);
onSearch?.(value);
}
</script>
<form onsubmit={handleSubmit} class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
{value}
{placeholder}
oninput={handleInput}
class="w-full pl-10 pr-10 py-2 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
/>
{#if value}
<button
type="button"
onclick={handleClear}
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
aria-label="Clear search"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</form>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
interface Props {
type?: "card" | "text" | "circle" | "banner" | "row";
count?: number;
width?: string;
height?: string;
aspectRatio?: "square" | "video" | "portrait";
}
let {
type = "card",
count = 1,
width = "100%",
height = "auto",
aspectRatio = "square",
}: Props = $props();
const aspectClasses = {
square: "aspect-square",
video: "aspect-video",
portrait: "aspect-[2/3]",
};
</script>
{#if type === "card"}
<div class="flex gap-4 overflow-hidden">
{#each Array(count) as _, i (i)}
<div class="flex-shrink-0 w-36 animate-pulse">
<div class="w-full {aspectClasses[aspectRatio]} bg-[var(--color-surface)] rounded-lg shimmer"></div>
<div class="mt-2 space-y-2">
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 80%"></div>
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 60%"></div>
</div>
</div>
{/each}
</div>
{:else if type === "banner"}
<div class="animate-pulse">
<div class="h-[500px] bg-[var(--color-surface)] rounded-xl shimmer"></div>
</div>
{:else if type === "circle"}
<div class="flex gap-4">
{#each Array(count) as _, i (i)}
<div class="flex flex-col items-center animate-pulse">
<div class="w-20 h-20 rounded-full bg-[var(--color-surface)] shimmer"></div>
<div class="mt-2 h-3 w-16 bg-[var(--color-surface)] rounded shimmer"></div>
</div>
{/each}
</div>
{:else if type === "row"}
<div class="space-y-4">
{#each Array(count) as _, i (i)}
<div class="flex gap-4 animate-pulse">
<div class="w-16 h-16 rounded bg-[var(--color-surface)] shimmer flex-shrink-0"></div>
<div class="flex-1 space-y-2 py-2">
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 70%"></div>
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 50%"></div>
</div>
</div>
{/each}
</div>
{:else if type === "text"}
<div class="space-y-2 animate-pulse">
{#each Array(count) as _, i (i)}
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: {width}; height: {height}"></div>
{/each}
</div>
{/if}
<style>
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.shimmer {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
var(--color-surface) 0%,
rgba(255, 255, 255, 0.05) 20%,
var(--color-surface) 40%,
var(--color-surface) 100%
);
background-size: 1000px 100%;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { toast } from "$lib/stores/toast";
import { fly, fade } from "svelte/transition";
import { quintOut } from "svelte/easing";
// Icons for different toast types
const icons = {
success: {
path: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
color: "text-green-500",
bg: "bg-green-500/10",
border: "border-green-500/20",
},
error: {
path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z",
color: "text-red-500",
bg: "bg-red-500/10",
border: "border-red-500/20",
},
warning: {
path: "M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z",
color: "text-yellow-500",
bg: "bg-yellow-500/10",
border: "border-yellow-500/20",
},
info: {
path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z",
color: "text-blue-500",
bg: "bg-blue-500/10",
border: "border-blue-500/20",
},
};
</script>
<!-- Toast Container -->
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{#each $toast.toasts as toastItem (toastItem.id)}
{@const style = icons[toastItem.type]}
<div
in:fly={{ y: -20, duration: 300, easing: quintOut }}
out:fade={{ duration: 200 }}
class="pointer-events-auto flex items-center gap-3 px-4 py-3 bg-[var(--color-surface)] backdrop-blur-lg border {style.border} rounded-lg shadow-2xl min-w-[300px] max-w-md"
>
<!-- Icon -->
<div class="flex-shrink-0 w-6 h-6 rounded-full {style.bg} flex items-center justify-center">
<svg class="w-4 h-4 {style.color}" fill="currentColor" viewBox="0 0 24 24">
<path d={style.path}/>
</svg>
</div>
<!-- Message -->
<p class="flex-1 text-sm text-white font-medium">
{toastItem.message}
</p>
<!-- Close button -->
<button
onclick={() => toast.dismiss(toastItem.id)}
class="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { auth, authError, isAuthLoading } from "$lib/stores";
interface Props {
isOpen?: boolean;
onSuccess?: () => void;
onDismiss?: () => void;
}
let { isOpen = false, onSuccess, onDismiss }: Props = $props();
let password = $state("");
let localError = $state<string | null>(null);
const session = auth.getCurrentSession();
const username = session?.username ?? "User";
const serverName = session?.serverName ?? "Jellyfin Server";
async function handleSubmit(event: Event) {
event.preventDefault();
localError = null;
if (!password.trim()) {
localError = "Please enter your password";
return;
}
try {
await auth.reauthenticate(password);
password = "";
onSuccess?.();
} catch (error) {
localError = error instanceof Error ? error.message : "Authentication failed";
}
}
function handleDismiss() {
auth.dismissReauth();
password = "";
localError = null;
onDismiss?.();
}
function handleBackdropClick(event: MouseEvent) {
// Don't close on backdrop click - require explicit action
event.stopPropagation();
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
role="dialog"
aria-modal="true"
aria-labelledby="reauth-title"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="none"
>
<!-- Header -->
<div class="px-6 pt-6 pb-4 text-center">
<!-- Lock icon -->
<div class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4">
<svg
class="w-8 h-8 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">
Session Expired
</h2>
<p class="text-sm text-gray-400">
Your session on <span class="text-white font-medium">{serverName}</span> has expired.
Please enter your password to continue.
</p>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="px-6 pb-6">
<!-- Username (read-only) -->
<div class="mb-4">
<div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label">
Username
</div>
<div class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm" aria-labelledby="reauth-username-label">
{username}
</div>
</div>
<!-- Password -->
<div class="mb-4">
<label for="reauth-password" class="block text-sm font-medium text-gray-400 mb-1">
Password
</label>
<input
id="reauth-password"
type="password"
bind:value={password}
placeholder="Enter your password"
disabled={$isAuthLoading}
class="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-[var(--color-jellyfin)] focus:ring-1 focus:ring-[var(--color-jellyfin)] transition-colors disabled:opacity-50"
/>
</div>
<!-- Error message -->
{#if localError || $authError}
<div class="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<p class="text-sm text-red-400">
{localError || $authError}
</p>
</div>
{/if}
<!-- Buttons -->
<div class="flex flex-col gap-2">
<button
type="submit"
disabled={$isAuthLoading}
class="w-full py-3 px-4 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)] text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if $isAuthLoading}
<svg class="animate-spin h-5 w-5" 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>
<span>Authenticating...</span>
{:else}
<span>Sign In</span>
{/if}
</button>
<button
type="button"
onclick={handleDismiss}
disabled={$isAuthLoading}
class="w-full py-3 px-4 rounded-lg border border-gray-700 hover:border-gray-600 text-gray-300 hover:text-white font-medium transition-colors disabled:opacity-50"
>
Continue Offline
</button>
</div>
<p class="mt-4 text-xs text-gray-500 text-center">
Some features may be unavailable in offline mode.
</p>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* BackButton component - Reusable back navigation button
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens (navigation)
*/
interface Props {
onClick: () => void;
label?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
let { onClick, label = "Back", size = "md", className = "" }: Props = $props();
const sizeMap = {
sm: "w-5 h-5",
md: "w-6 h-6",
lg: "w-8 h-8",
};
</script>
<button
onclick={onClick}
aria-label={label}
class={`text-gray-400 hover:text-white transition-colors ${className}`}
>
<svg class={`${sizeMap[size]} fill-none stroke-current`} 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>
@@ -0,0 +1,87 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { auth } from "$lib/stores/auth";
import { get } from "svelte/store";
interface Props {
itemId: string;
imageType?: string;
tag?: string;
maxWidth?: number;
maxHeight?: number;
class?: string;
alt?: string;
}
let {
itemId,
imageType = "Primary",
tag,
maxWidth,
maxHeight,
class: className = "",
alt = "",
}: Props = $props();
let imageUrl = $state<string | null>(null);
let loading = $state(true);
let error = $state(false);
async function loadImage() {
if (!itemId) {
loading = false;
return;
}
try {
loading = true;
error = false;
// Get repository handle from auth store
const authState = get(auth);
if (!authState.isAuthenticated) {
throw new Error("Not authenticated");
}
const repository = auth.getRepository();
const repositoryHandle = repository.getHandle();
// Call Rust to get image as base64 data URL
const dataUrl = await invoke<string>("image_get_url", {
repositoryHandle,
request: {
itemId,
imageType,
maxWidth,
maxHeight,
tag,
},
});
// Use data URL directly
imageUrl = dataUrl;
error = false;
} catch (e) {
console.error(`Failed to load image ${itemId}:`, e);
error = true;
imageUrl = null;
} finally {
loading = false;
}
}
// Reload image when props change
$effect(() => {
imageUrl = null;
loadImage();
});
</script>
{#if loading}
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
{:else if error}
<div class="{className} bg-gray-800 flex items-center justify-center">
<span class="text-gray-500 text-xs">Failed to load</span>
</div>
{:else if imageUrl}
<img src={imageUrl} {alt} class={className} />
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
/**
* ResultsCounter component - Shows item count with optional search context
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens
*/
interface Props {
count: number;
itemType: string; // "genre", "album", "track", "artist", "movie", "show", etc.
searchQuery?: string;
className?: string;
}
let { count, itemType, searchQuery = "", className = "" }: Props = $props();
const itemTypeLabels: Record<string, { singular: string; plural: string }> = {
genre: { singular: "genre", plural: "genres" },
album: { singular: "album", plural: "albums" },
track: { singular: "track", plural: "tracks" },
artist: { singular: "artist", plural: "artists" },
movie: { singular: "movie", plural: "movies" },
show: { singular: "show", plural: "shows" },
playlist: { singular: "playlist", plural: "playlists" },
};
const labels = itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` };
const label = count === 1 ? labels.singular : labels.plural;
</script>
<p class={`text-sm text-gray-400 ${className}`}>
{count}
{label}
{#if searchQuery}
matching "{searchQuery}"
{/if}
</p>
@@ -0,0 +1,48 @@
<script lang="ts">
/**
* SearchBar component - Reusable search input with icon
*
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens (search component)
* @req: DR-011 - Search bar with cross-library search
*/
interface Props {
value: string;
placeholder?: string;
onInput: (value: string) => void;
className?: string;
}
let { value, placeholder = "Search...", onInput, className = "" }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
onInput(target.value);
}
</script>
<div class={`relative ${className}`}>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
{placeholder}
{value}
oninput={handleInput}
class="w-full pl-10 pr-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
/>
</div>
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import SearchBar from "./SearchBar.svelte";
describe("SearchBar", () => {
describe("Rendering Tests", () => {
it("should render input field with placeholder", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search test...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search test...");
expect(input).toBeTruthy();
});
it("should render search icon", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
const classString = svg?.getAttribute("class") || "";
expect(classString).toContain("w-5");
expect(classString).toContain("h-5");
});
it("should display current value in input", () => {
render(SearchBar, {
props: {
value: "test query",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("test query") as HTMLInputElement;
expect(input.value).toBe("test query");
});
it("should apply custom className", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
className: "custom-class",
},
});
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("custom-class");
});
it("should have proper accessibility attributes", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search genres...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search genres...") as HTMLInputElement;
expect(input.type).toBe("text");
});
});
describe("Interaction Tests", () => {
it("should call onInput callback when user types", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "test" } });
expect(onInput).toHaveBeenCalled();
});
it("should pass correct value to onInput callback", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "album search" } });
// Check that callback was called with the typed value
expect(onInput).toHaveBeenCalledWith("album search");
});
it("should handle multiple input changes", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "test" } });
fireEvent.input(input, { target: { value: "testing" } });
expect(onInput).toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle empty search query", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
expect(input.value).toBe("");
});
it("should handle special characters in value", () => {
render(SearchBar, {
props: {
value: '@$%^&*()',
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("@$%^&*()") as HTMLInputElement;
expect(input.value).toBe("@$%^&*()");
});
it("should handle very long input values", () => {
const longValue = "a".repeat(500);
render(SearchBar, {
props: {
value: longValue,
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue(longValue) as HTMLInputElement;
expect(input.value).toBe(longValue);
});
it("should work with numeric input", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "12345" } });
expect(onInput).toHaveBeenCalledWith("12345");
});
});
describe("Requirement Tests", () => {
it("should support searching with spaces", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "search multiple words" } });
expect(onInput).toHaveBeenCalledWith("search multiple words");
});
it("should work as controlled component with value prop", () => {
render(SearchBar, {
props: {
value: "initial value",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("initial value") as HTMLInputElement;
expect(input.value).toBe("initial value");
});
it("should have proper styling for dark theme", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = container.querySelector("input");
expect(input).toBeTruthy();
const classString = input?.getAttribute("class") || "";
expect(classString.length).toBeGreaterThan(0);
expect(classString).toContain("bg-");
expect(classString).toContain("text-white");
expect(classString).toContain("placeholder-gray");
});
});
});
@@ -0,0 +1,58 @@
<script lang="ts">
/**
* SortButtonGroup component - Button group for sorting options
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens
*/
export interface SortOption {
key: string;
label: string;
}
interface Props {
options: SortOption[];
selected: string;
onSelect: (key: string) => void;
className?: string;
}
let { options, selected, onSelect, className = "" }: Props = $props();
function handleClick(key: string) {
onSelect(key);
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (e.key === "ArrowRight" && index < options.length - 1) {
e.preventDefault();
onSelect(options[index + 1].key);
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault();
onSelect(options[index - 1].key);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(options[index].key);
}
}
</script>
<div class={`flex flex-wrap gap-2 ${className}`}>
{#each options as option, index (option.key)}
<button
onclick={() => handleClick(option.key)}
onkeydown={(e) => handleKeydown(e, index)}
role="radio"
aria-checked={selected === option.key}
tabindex={selected === option.key ? 0 : -1}
class={`px-4 py-3 rounded-lg font-medium transition-colors ${
selected === option.key
? "bg-[var(--color-jellyfin)] text-white"
: "bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]"
}`}
>
{option.label}
</button>
{/each}
</div>
@@ -0,0 +1,293 @@
<script lang="ts">
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
interface Props {
download: DownloadInfo;
}
let { download }: Props = $props();
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatProgress(): string {
if (!download.fileSize) {
return formatBytes(download.bytesDownloaded);
}
return `${formatBytes(download.bytesDownloaded)} / ${formatBytes(download.fileSize)}`;
}
function getStatusColor(): string {
switch (download.status) {
case "downloading":
return "bg-blue-500";
case "completed":
return "bg-green-500";
case "failed":
return "bg-red-500";
case "paused":
return "bg-yellow-500";
default:
return "bg-gray-500";
}
}
function getStatusText(): string {
switch (download.status) {
case "pending":
return "Queued";
case "downloading":
return "Downloading";
case "completed":
return "Completed";
case "failed":
return download.errorMessage || "Failed";
case "paused":
return "Paused";
default:
return "Unknown";
}
}
function getSourceBorderColor(): string {
// Green for user downloads, blue for auto-cached
return download.downloadSource === 'user' ? 'border-green-500/50' : 'border-blue-500/50';
}
function getSourceLabel(): string {
return download.downloadSource === 'user' ? 'Downloaded' : 'Auto-Cached';
}
async function handlePause() {
try {
await downloads.pause(download.id);
// Refresh to update UI
await downloads.refresh(download.userId);
} catch (error) {
console.error("Failed to pause download:", error);
}
}
async function handleResume() {
try {
await downloads.resume(download.id);
// Refresh to update UI
await downloads.refresh(download.userId);
} catch (error) {
console.error("Failed to resume download:", error);
}
}
async function handleCancel() {
try {
await downloads.cancel(download.id);
// Refresh to update UI
await downloads.refresh(download.userId);
} catch (error) {
console.error("Failed to cancel download:", error);
}
}
async function handleDelete() {
try {
await downloads.delete(download.id);
// Refresh to update UI
await downloads.refresh(download.userId);
} catch (error) {
console.error("Failed to delete download:", error);
}
}
</script>
<div class="bg-[var(--color-surface)] rounded-lg p-4 hover:bg-[var(--color-surface-hover)] transition-colors border-l-4 {getSourceBorderColor()}">
<div class="flex items-center gap-4">
<!-- Status Indicator -->
<div class="w-2 h-2 rounded-full {getStatusColor()} flex-shrink-0"></div>
<!-- Media Type Icon -->
<div class="flex-shrink-0 text-gray-500">
{#if download.mediaType === "video"}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
</svg>
{/if}
</div>
<!-- Download Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<div class="min-w-0 flex-1">
<p class="text-white font-medium truncate">{download.itemName || download.itemId}</p>
{#if download.mediaType === "video" && download.seriesName}
<!-- Video: Show series info and episode number -->
<p class="text-xs text-gray-400 truncate">
{download.seriesName}
{#if download.seasonNumber !== undefined && download.episodeNumber !== undefined}
<span class="text-gray-500"> • S{String(download.seasonNumber).padStart(2, '0')}E{String(download.episodeNumber).padStart(2, '0')}</span>
{/if}
{#if download.qualityPreset && download.qualityPreset !== "original"}
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
{/if}
</p>
{:else if download.mediaType === "video"}
<!-- Movie: Show quality badge -->
<p class="text-xs text-gray-400 truncate">
Movie
{#if download.qualityPreset && download.qualityPreset !== "original"}
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
{/if}
</p>
{:else if download.artistName || download.albumName}
<!-- Audio: Show artist and album -->
<p class="text-xs text-gray-400 truncate">
{download.artistName}{download.artistName && download.albumName ? ' • ' : ''}{download.albumName}
</p>
{/if}
</div>
<div class="flex items-center gap-2 ml-2 flex-shrink-0">
<span class="text-xs text-gray-400">{getStatusText()}</span>
{#if download.downloadSource === 'auto'}
<span class="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded uppercase font-semibold" title="Automatically cached">Auto</span>
{/if}
</div>
</div>
<!-- Progress Bar (for active/paused downloads) -->
{#if download.status === "downloading" || download.status === "paused"}
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {download.progress * 100}%"
></div>
</div>
<div class="flex items-center justify-between text-xs text-gray-400">
<span>{Math.round(download.progress * 100)}%</span>
<span>{formatProgress()}</span>
</div>
{:else if download.status === "completed"}
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
{:else if download.status === "failed"}
<p class="text-xs text-red-400">{download.errorMessage || "Download failed"}</p>
{:else if download.status === "pending"}
<p class="text-xs text-gray-400">
{#if download.fileSize}
{formatBytes(download.fileSize)}
{:else}
Waiting to start...
{/if}
</p>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
{#if download.status === "downloading"}
<!-- Pause Button -->
<button
onclick={handlePause}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Pause download"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</button>
<!-- Cancel Button -->
<button
onclick={handleCancel}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{:else if download.status === "paused"}
<!-- Resume Button -->
<button
onclick={handleResume}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Resume download"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<!-- Cancel Button -->
<button
onclick={handleCancel}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{:else if download.status === "pending"}
<!-- Cancel Button -->
<button
onclick={handleCancel}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{:else if download.status === "completed"}
<!-- Delete Button -->
<button
onclick={handleDelete}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{:else if download.status === "failed"}
<!-- Retry Button -->
<button
onclick={handleResume}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Retry download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<!-- Delete Button -->
<button
onclick={handleDelete}
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete failed download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,231 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation";
interface AlbumStorageInfo {
album_id: string;
album_name: string;
artist_name: string | null;
bytes_used: number;
track_count: number;
}
interface StorageStats {
total_bytes: number;
total_items: number;
albums: AlbumStorageInfo[];
}
let stats = $state<StorageStats | null>(null);
let loading = $state(true);
let deleting = $state(false);
let deletingAlbum = $state<string | null>(null);
let showDeleteAllConfirm = $state(false);
let showBreakdown = $state(false);
$effect(() => {
loadStats();
});
async function loadStats() {
try {
loading = true;
const userId = $auth.user?.id;
if (userId) {
stats = await invoke<StorageStats>("get_download_storage_stats", { userId });
}
} catch (error) {
console.error("Failed to load storage stats:", error);
} finally {
loading = false;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
async function deleteAllDownloads() {
try {
deleting = true;
const userId = $auth.user?.id;
if (userId) {
await invoke("delete_all_downloads", { userId });
await downloads.refresh(userId);
await loadStats();
}
} catch (error) {
console.error("Failed to delete all downloads:", error);
} finally {
deleting = false;
showDeleteAllConfirm = false;
}
}
async function deleteAlbumDownloads(albumId: string) {
try {
deletingAlbum = albumId;
const userId = $auth.user?.id;
if (userId) {
await invoke("delete_album_downloads", { albumId, userId });
await downloads.refresh(userId);
await loadStats();
}
} catch (error) {
console.error("Failed to delete album downloads:", error);
} finally {
deletingAlbum = null;
}
}
function handleAlbumClick(albumId: string) {
if (albumId !== "unknown") {
goto(`/library/${albumId}`);
}
}
</script>
<div class="bg-[var(--color-surface)] rounded-xl p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Storage</h2>
{#if stats && stats.total_items > 0}
<button
onclick={() => (showDeleteAllConfirm = true)}
class="px-4 py-2 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
>
Delete All
</button>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center py-8">
<div class="w-6 h-6 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if stats}
<!-- Storage Summary -->
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center">
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-white">{formatBytes(stats.total_bytes)}</p>
<p class="text-sm text-gray-400">
{stats.total_items} {stats.total_items === 1 ? "item" : "items"} downloaded
</p>
</div>
</div>
<!-- Storage Breakdown Toggle -->
{#if stats.albums.length > 0}
<button
onclick={() => (showBreakdown = !showBreakdown)}
class="w-full flex items-center justify-between py-3 px-4 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
>
<span class="text-sm text-gray-300">Storage by album</span>
<svg
class="w-5 h-5 text-gray-400 transition-transform {showBreakdown ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Album Breakdown -->
{#if showBreakdown}
<div class="space-y-2 max-h-64 overflow-y-auto">
{#each stats.albums as album (album.album_id)}
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors">
<button
onclick={() => handleAlbumClick(album.album_id)}
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">
{album.album_name}
</p>
<p class="text-xs text-gray-400 truncate">
{album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"}
</p>
</button>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-sm text-gray-400">{formatBytes(album.bytes_used)}</span>
<button
onclick={() => deleteAlbumDownloads(album.album_id)}
disabled={deletingAlbum === album.album_id}
class="p-1.5 rounded-full text-gray-400 hover:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
title="Delete album downloads"
>
{#if deletingAlbum === album.album_id}
<div class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Empty State -->
{#if stats.total_items === 0}
<div class="text-center py-4">
<p class="text-gray-400 text-sm">No downloads yet</p>
<p class="text-gray-500 text-xs mt-1">Downloaded media will appear here</p>
</div>
{/if}
{/if}
</div>
<!-- Delete All Confirmation Modal -->
{#if showDeleteAllConfirm}
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl">
<div class="p-6 text-center">
<div class="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-red-500" 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>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Delete All Downloads?</h3>
<p class="text-sm text-gray-400 mb-6">
This will remove {stats?.total_items || 0} downloaded items and free up {formatBytes(stats?.total_bytes || 0)} of storage. This action cannot be undone.
</p>
<div class="flex gap-3">
<button
onclick={() => (showDeleteAllConfirm = false)}
class="flex-1 px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
>
Cancel
</button>
<button
onclick={deleteAllDownloads}
disabled={deleting}
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{#if deleting}
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Deleting...
{:else}
Delete All
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import MediaCard from "$lib/components/library/MediaCard.svelte";
interface Props {
title: string;
items: MediaItem[];
onItemClick?: (item: MediaItem) => void;
showAll?: () => void;
}
let { title, items, onItemClick, showAll }: Props = $props();
let scrollContainer: HTMLDivElement | null = $state(null);
let showLeftArrow = $state(false);
let showRightArrow = $state(true);
function handleScroll() {
if (!scrollContainer) return;
showLeftArrow = scrollContainer.scrollLeft > 0;
showRightArrow =
scrollContainer.scrollLeft <
scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
}
function scrollLeft() {
scrollContainer?.scrollBy({ left: -600, behavior: "smooth" });
}
function scrollRight() {
scrollContainer?.scrollBy({ left: 600, behavior: "smooth" });
}
</script>
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h2 class="text-2xl font-semibold text-white">{title}</h2>
{#if showAll}
<button
onclick={showAll}
class="text-sm text-gray-400 hover:text-white transition-colors"
>
See all
</button>
{/if}
</div>
<!-- Scrollable row -->
<div class="relative group">
<div
bind:this={scrollContainer}
onscroll={handleScroll}
class="flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth px-4 pb-4"
>
{#each items as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
<!-- Navigation arrows (Spotify-style - only show on hover) -->
{#if showLeftArrow}
<button
onclick={scrollLeft}
class="absolute left-0 top-1/2 -translate-y-1/2 p-2 bg-black/80 hover:bg-black rounded-full opacity-0 group-hover:opacity-100 transition-opacity z-10 ml-2"
aria-label="Scroll left"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
{/if}
{#if showRightArrow}
<button
onclick={scrollRight}
class="absolute right-0 top-1/2 -translate-y-1/2 p-2 bg-black/80 hover:bg-black rounded-full opacity-0 group-hover:opacity-100 transition-opacity z-10 mr-2"
aria-label="Scroll right"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
{/if}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
interface Props {
items: MediaItem[];
autoRotate?: boolean;
interval?: number;
}
let { items, autoRotate = true, interval = 6000 }: Props = $props();
let currentIndex = $state(0);
let intervalId: number | null = null;
// Touch/swipe state
let touchStartX = $state(0);
let touchEndX = $state(0);
let isSwiping = $state(false);
const currentItem = $derived(items[currentIndex] ?? null);
function getHeroImageUrl(): string {
if (!currentItem) return "";
const repo = auth.getRepository();
// 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) {
return repo.getImageUrl(currentItem.id, "Backdrop", {
maxWidth: 1920,
tag: currentItem.backdropImageTags[0],
});
}
// 2. For episodes, try to use series backdrop from parent
if (currentItem.type === "Episode") {
// First try parent backdrop tags (includes image tag for caching)
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
tag: currentItem.parentBackdropImageTags[0],
});
}
// Fallback: try series backdrop without tag (may not be cached optimally)
if (currentItem.seriesId) {
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
});
}
// Last resort for episodes: try season backdrop
if (currentItem.seasonId) {
return repo.getImageUrl(currentItem.seasonId, "Backdrop", {
maxWidth: 1920,
});
}
}
// 3. For music tracks, try album backdrop first, then primary
if (currentItem.type === "Audio" && currentItem.albumId) {
// Try album backdrop first (more cinematic for hero)
return repo.getImageUrl(currentItem.albumId, "Backdrop", {
maxWidth: 1920,
});
}
// 4. Fall back to primary image (poster, album art, episode thumbnail)
if (currentItem.primaryImageTag) {
return repo.getImageUrl(currentItem.id, "Primary", {
maxWidth: 1920,
tag: currentItem.primaryImageTag,
});
}
// 5. Last resort for audio: try album primary image
if (currentItem.type === "Audio" && currentItem.albumId) {
return repo.getImageUrl(currentItem.albumId, "Primary", {
maxWidth: 1920,
});
}
return "";
}
function next() {
currentIndex = (currentIndex + 1) % items.length;
}
function prev() {
currentIndex = (currentIndex - 1 + items.length) % items.length;
}
function goToIndex(idx: number) {
currentIndex = idx;
}
// Touch/swipe handlers
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
isSwiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping) return;
touchEndX = e.touches[0].clientX;
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
const swipeThreshold = 50; // Minimum swipe distance in pixels
const diff = touchStartX - touchEndX;
if (Math.abs(diff) > swipeThreshold) {
if (diff > 0) {
// Swiped left - go to next
next();
} else {
// Swiped right - go to previous
prev();
}
}
touchStartX = 0;
touchEndX = 0;
}
// Auto-rotate logic
$effect(() => {
if (autoRotate && items.length > 1) {
intervalId = window.setInterval(next, interval);
return () => {
if (intervalId) clearInterval(intervalId);
};
}
});
const heroImageUrl = $derived(getHeroImageUrl());
</script>
<div
class="relative h-[500px] rounded-xl overflow-hidden group mb-8 touch-pan-y"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
{#if heroImageUrl}
<img
src={heroImageUrl}
alt={currentItem?.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>
{#if currentItem}
<!-- Content -->
<div class="relative h-full flex flex-col justify-end p-12 max-w-3xl">
<div class="space-y-4">
<h1 class="text-5xl font-bold text-white drop-shadow-lg">
{currentItem.name}
</h1>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-200">
{#if currentItem.productionYear}
<span>{currentItem.productionYear}</span>
{/if}
{#if currentItem.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>
{currentItem.communityRating.toFixed(1)}
</span>
{/if}
{#if currentItem.officialRating}
<span class="px-2 py-0.5 border border-gray-300 rounded text-xs">
{currentItem.officialRating}
</span>
{/if}
</div>
{#if currentItem.overview}
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed">
{currentItem.overview}
</p>
{/if}
<!-- Actions -->
<div class="flex gap-3 pt-2">
<button
onclick={() => goto(`/player/${currentItem.id}`)}
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>
Play
</button>
<button
onclick={() => {
// Navigate to full series detail page with cast/crew/related content
// (even for episodes, show the series page so users see cast and related items)
if (currentItem.type === "Episode" && currentItem.seriesId) {
goto(`/library/${currentItem.seriesId}`);
} else {
goto(`/library/${currentItem.id}`);
}
}}
class="px-8 py-3 bg-gray-600/80 hover:bg-gray-600 backdrop-blur-sm rounded-lg font-semibold text-lg transition-colors"
>
More Info
</button>
</div>
</div>
</div>
<!-- Navigation -->
{#if items.length > 1}
<!-- Indicators / Location Bar -->
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full">
{#each items as _, idx}
<button
onclick={() => goToIndex(idx)}
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx === currentIndex ? 'bg-white w-12' : 'bg-white/50 w-8'}"
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ''}`}
></button>
{/each}
</div>
<!-- Swipe Indicators (Desktop hover) -->
<button
onclick={prev}
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
aria-label="Previous item"
>
<svg class="w-6 h-6 text-white" 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>
<button
onclick={next}
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
aria-label="Next item"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
{/if}
{/if}
</div>
@@ -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>
+195
View File
@@ -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();
});
});
});
+495
View File
@@ -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}
@@ -0,0 +1,355 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
import {
mergedMedia,
mergedIsPlaying,
mergedPosition,
mergedDuration
} from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession } from "$lib/stores/sessions";
import { formatTime } from "$lib/utils/playbackUnits";
import Controls from "./Controls.svelte";
import Queue from "./Queue.svelte";
import CastButton from "$lib/components/sessions/CastButton.svelte";
import SleepTimerModal from "./SleepTimerModal.svelte";
import VolumeControl from "./VolumeControl.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { currentQueueItem } from "$lib/stores/queue";
interface Props {
media: MediaItem | null;
isPlaying?: boolean;
position?: number;
duration?: number;
shuffle?: boolean;
repeat?: "off" | "all" | "one";
hasNext?: boolean;
hasPrevious?: boolean;
onClose?: () => void;
}
let {
media,
isPlaying = false,
position = 0,
duration = 0,
shuffle = false,
repeat = "off",
hasNext = false,
hasPrevious = false,
onClose,
}: Props = $props();
let seeking = $state(false);
let seekValue = $state(0);
let seekPending = $state(false); // True while waiting for backend to confirm seek
let showSleepTimerModal = $state(false);
let showQueue = $state(false);
// Use merged media store for audio player display (handles both local and remote playback)
// In remote mode, this automatically uses the remote session's nowPlayingItem
// In local mode, falls back to queue item for complete metadata
const displayMedia = $derived($mergedMedia || $currentQueueItem);
const displayIsPlaying = $derived($mergedIsPlaying);
const rawPosition = $derived($mergedPosition);
const displayDuration = $derived($mergedDuration);
function handleSeekStart() {
seeking = true;
seekValue = rawPosition;
}
function handleSeekInput(e: Event) {
const target = e.target as HTMLInputElement;
seekValue = parseFloat(target.value);
}
async function handleSeekEnd() {
seeking = false;
seekPending = true; // Keep showing target position until backend catches up
await invoke("player_seek", { position: seekValue });
}
// Control handlers for Controls component
async function handlePlayPause() {
await invoke("player_toggle");
}
async function handlePrevious() {
await invoke("player_previous");
}
async function handleNext() {
await invoke("player_next");
}
async function handleToggleShuffle() {
await invoke("player_toggle_shuffle");
}
async function handleCycleRepeat() {
await invoke("player_cycle_repeat");
}
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
// Album art is inherited from album, so all tracks show the same album cover
const artworkItemId = $derived(displayMedia?.id);
// Show optimistic position while seeking or waiting for backend confirmation
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
// Clear pending state when backend position catches up to our seek target
$effect(() => {
if (seekPending && Math.abs(rawPosition - seekValue) < 2) {
seekPending = false;
}
});
function navigateToArtist(artistId: string) {
onClose?.();
goto(`/library/${artistId}`);
}
function navigateToAlbum() {
const currentMedia = displayMedia;
if (currentMedia?.albumId) {
onClose?.();
goto(`/library/${currentMedia.albumId}`);
}
}
async function handleQueueItemClick(index: number) {
try {
queue.skipTo(index);
await invoke("player_skip_to", { index });
} catch (e) {
console.error("Failed to skip to queue item:", e);
}
}
</script>
{#if displayMedia}
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
<!-- Background image (blurred) -->
{#if artworkItemId && displayMedia?.primaryImageTag}
<div class="fixed inset-0 z-0">
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia.primaryImageTag}
maxWidth={800}
alt=""
class="w-full h-full object-cover blur-3xl opacity-30"
/>
<div class="absolute inset-0 bg-gradient-to-b from-black/60 via-black/80 to-black"></div>
</div>
{:else}
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
{/if}
<!-- Content overlay -->
<div class="relative z-10 flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 flex-shrink-0">
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close player"
>
<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="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="flex flex-col items-center">
<p class="text-sm text-gray-400">Now Playing</p>
{#if $isRemoteMode && $selectedSession}
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
{$selectedSession.deviceName}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Cast Button -->
<CastButton size="md" />
<!-- Queue Button -->
<button
onclick={() => (showQueue = !showQueue)}
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue ? 'bg-white/10 text-[var(--color-jellyfin)]' : ''}"
title="Queue"
aria-label="Open queue"
>
<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="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
<button
onclick={() => (showSleepTimerModal = true)}
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
title="Sleep timer"
aria-label="Sleep timer"
>
<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="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
{#if $sleepTimerActive}
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
{/if}
</button>
<!-- Volume Control (Linux only) -->
<VolumeControl size="md" />
</div>
</div>
<!-- Artwork -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
{#if artworkItemId && displayMedia?.primaryImageTag}
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia.primaryImageTag}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<svg class="w-32 h-32 text-gray-600" 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}
</div>
</div>
<!-- Info & Controls -->
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
<button
onclick={() => navigateToArtist(artist.id)}
class="hover:text-white hover:underline transition-colors"
>
{artist.name}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
{/each}
{:else if displayMedia?.artists?.length}
<span>{displayMedia?.artists.join(", ")}</span>
{/if}
{#if displayMedia?.albumId && displayMedia?.albumName}
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
<span class="text-gray-500"></span>
{/if}
<button
onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors"
>
{displayMedia?.albumName}
</button>
{:else if displayMedia?.albumName}
<span>{displayMedia?.albumName}</span>
{/if}
</div>
</div>
<!-- Progress bar -->
<div class="space-y-2">
<input
type="range"
min="0"
max={displayDuration}
value={displayPosition}
oninput={handleSeekInput}
onmousedown={handleSeekStart}
ontouchstart={handleSeekStart}
onmouseup={handleSeekEnd}
ontouchend={handleSeekEnd}
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>{formatTime(displayPosition)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
<!-- Controls -->
<div class="flex justify-center">
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
onSleepTimerClick={() => (showSleepTimerModal = true)}
/>
</div>
</div>
</div> <!-- Close content overlay -->
</div>
{/if}
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => (showSleepTimerModal = false)}
/>
<!-- Queue Panel (slide up from bottom) -->
{#if showQueue}
<div class="fixed inset-0 z-[60]">
<!-- Backdrop -->
<button
type="button"
class="absolute inset-0 bg-black/50"
onclick={() => (showQueue = false)}
aria-label="Close queue"
></button>
<!-- Queue Panel -->
<div class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up">
<Queue
items={$queueItems}
currentIndex={$currentQueueIndex}
onItemClick={handleQueueItemClick}
onClose={() => (showQueue = false)}
/>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script lang="ts">
import { untrack } from "svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
interface Props {
isPlaying?: boolean;
hasPrevious?: boolean;
hasNext?: boolean;
shuffle?: boolean;
repeat?: "off" | "all" | "one";
onPlayPause?: () => void;
onPrevious?: () => void;
onNext?: () => void;
onToggleShuffle?: () => void;
onCycleRepeat?: () => void;
onSleepTimerClick?: () => void;
}
let {
isPlaying = false,
hasPrevious = false,
hasNext = false,
shuffle = false,
repeat = "off",
onPlayPause,
onPrevious,
onNext,
onToggleShuffle,
onCycleRepeat,
onSleepTimerClick,
}: Props = $props();
// Local optimistic state for instant button feedback
let optimisticIsPlaying = $state(false);
let optimisticTimeout: ReturnType<typeof setTimeout> | null = null;
// Sync with prop changes (initializes and updates on prop change)
$effect(() => {
optimisticIsPlaying = isPlaying;
// Clear timeout when prop updates (state confirmed)
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
optimisticTimeout = null;
}
});
// Cleanup on unmount
$effect(() => {
return () => {
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
}
};
});
function handlePlayPause() {
// Immediately toggle optimistic state for instant visual feedback
optimisticIsPlaying = !optimisticIsPlaying;
// Clear any pending timeout
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
}
// Reset optimistic state after a delay if prop doesn't update
optimisticTimeout = setTimeout(() => {
optimisticIsPlaying = isPlaying;
}, 1000);
// Call the actual handler
untrack(() => onPlayPause?.());
}
</script>
<div class="flex items-center gap-4">
<!-- Shuffle -->
<button
onclick={(e) => {
e.stopPropagation();
onToggleShuffle?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {shuffle
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</svg>
</button>
<!-- Sleep Timer Indicator -->
<SleepTimerIndicator onClick={onSleepTimerClick} />
<!-- Previous -->
<button
onclick={(e) => {
e.stopPropagation();
onPrevious?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
disabled={!hasPrevious}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Previous"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<!-- Play/Pause -->
<button
onclick={(e) => {
e.stopPropagation();
handlePlayPause();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-3 rounded-full bg-white text-black hover:scale-105 transition-transform"
title={optimisticIsPlaying ? "Pause" : "Play"}
>
{#if optimisticIsPlaying}
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next -->
<button
onclick={(e) => {
e.stopPropagation();
onNext?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
disabled={!hasNext}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Next"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
<!-- Repeat -->
<button
onclick={(e) => {
e.stopPropagation();
onCycleRepeat?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {repeat !== 'off'
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Repeat: {repeat}"
>
{#if repeat === "one"}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
{/if}
</button>
</div>
+487
View File
@@ -0,0 +1,487 @@
<script lang="ts">
/**
* MiniPlayer component - Always-visible bottom bar audio player
*
* Shows current track, playback controls, and progress for audio content.
* Automatically hides for video content (Movie/Episode).
* Supports both local and remote playback modes.
*
* @req: UR-005 - Control media playback (pause, play, skip, scrub)
* @req: DR-009 - Audio player UI (mini player)
* @req: UR-028 - Navigate to artist/album by tapping names in now playing view
* @req: UR-017 - Like or unlike audio, albums, movies, etc.
* @req: UR-010 - Control playback of Jellyfin remote sessions
*/
import { invoke } from "@tauri-apps/api/core";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import {
mergedMedia,
mergedIsPlaying,
mergedPosition,
mergedDuration,
shouldShowAudioMiniPlayer
} from "$lib/stores/player";
import { currentQueueItem } from "$lib/stores/queue";
import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession } from "$lib/stores/sessions";
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
import { haptics } from "$lib/utils/haptics";
import { toast } from "$lib/stores/toast";
import Controls from "./Controls.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import CastButton from "$lib/components/sessions/CastButton.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import VolumeControl from "./VolumeControl.svelte";
import CachedImage from "../common/CachedImage.svelte";
interface Props {
media: MediaItem | null;
isPlaying?: boolean;
position?: number;
duration?: number;
shuffle?: boolean;
repeat?: "off" | "all" | "one";
hasNext?: boolean;
hasPrevious?: boolean;
onExpand?: () => void;
onSleepTimerClick?: () => void;
className?: string;
}
let {
media,
isPlaying = false,
position = 0,
duration = 0,
shuffle = false,
repeat = "off",
hasNext = false,
hasPrevious = false,
onExpand,
onSleepTimerClick,
className = "",
}: Props = $props();
// Use merged media store for audio player display (handles both local and remote playback)
// In remote mode, this automatically uses the remote session's nowPlayingItem
const displayMedia = $derived($mergedMedia || $currentQueueItem);
const displayIsPlaying = $derived($mergedIsPlaying);
const displayPosition = $derived($mergedPosition);
const displayDuration = $derived($mergedDuration);
// State machine gated visibility - only show when player is playing/paused AND media is audio
const shouldShow = $derived($shouldShowAudioMiniPlayer);
const progress = $derived(
calculateProgress(displayPosition, displayDuration)
);
function navigateToArtist(event: MouseEvent, artistId: string) {
event.stopPropagation();
goto(`/library/${artistId}`);
}
function navigateToAlbum(event: MouseEvent) {
const currentMedia = displayMedia;
if (currentMedia?.albumId) {
event.stopPropagation();
goto(`/library/${currentMedia.albumId}`);
}
}
// Swipe gesture state
let touchStartX = $state(0);
let touchStartY = $state(0);
let touchEndX = $state(0);
let touchEndY = $state(0);
let isSwiping = $state(false);
let swipeTransform = $state(0);
// Overflow menu state
let showOverflowMenu = $state(false);
// Control handlers for Controls component
async function handlePlayPause() {
await invoke("player_toggle");
}
async function handlePrevious() {
await invoke("player_previous");
}
async function handleNext() {
await invoke("player_next");
}
async function handleToggleShuffle() {
await invoke("player_toggle_shuffle");
}
async function handleCycleRepeat() {
await invoke("player_cycle_repeat");
}
// Scrubbing (seek) handler
async function handleSeek(e: MouseEvent) {
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const newPosition = percent * displayDuration;
try {
await invoke("player_seek", { position: newPosition });
haptics.tap();
} catch (err) {
console.error("Failed to seek:", err);
toast.show("Failed to seek", "error");
}
}
// Swipe gesture handlers
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
isSwiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping) return;
touchEndX = e.touches[0].clientX;
touchEndY = e.touches[0].clientY;
const diffX = touchStartX - touchEndX;
const diffY = touchStartY - touchEndY;
// Only transform if horizontal swipe is dominant
if (Math.abs(diffX) > Math.abs(diffY)) {
swipeTransform = -diffX;
// Prevent default to stop scrolling
e.preventDefault();
}
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
const diffX = touchStartX - touchEndX;
const diffY = touchStartY - touchEndY;
const swipeThreshold = 80;
const minSwipeDistance = 20; // Minimum distance to be considered a swipe (not a tap)
// Only process if there was meaningful movement
const totalDistance = Math.sqrt(diffX * diffX + diffY * diffY);
if (totalDistance < minSwipeDistance) {
// This was a tap, not a swipe - ignore it
swipeTransform = 0;
touchStartX = 0;
touchStartY = 0;
touchEndX = 0;
touchEndY = 0;
return;
}
// Determine swipe direction
if (Math.abs(diffX) > Math.abs(diffY)) {
// Horizontal swipe
if (Math.abs(diffX) > swipeThreshold) {
if (diffX > 0) {
// Swiped left - Next track
haptics.tap();
handleNext();
toast.show("Next track", "info", 1000);
} else {
// Swiped right - Previous track
haptics.tap();
handlePrevious();
toast.show("Previous track", "info", 1000);
}
}
} else {
// Vertical swipe
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
// Swiped up - Open full player
console.log("[MiniPlayer] Swipe-up detected, expanding player");
haptics.tap();
onExpand?.();
}
}
// Reset transform
swipeTransform = 0;
touchStartX = 0;
touchStartY = 0;
touchEndX = 0;
touchEndY = 0;
}
// Overflow menu actions
function handleAddToPlaylist() {
showOverflowMenu = false;
haptics.tap();
toast.show("Add to playlist coming soon!", "info");
}
function handleGoToAlbum() {
showOverflowMenu = false;
if (displayMedia?.albumId) {
haptics.tap();
goto(`/library/${displayMedia.albumId}`);
}
}
function handleGoToArtist() {
showOverflowMenu = false;
if (displayMedia?.artistItems?.[0]?.id) {
haptics.tap();
goto(`/library/${displayMedia.artistItems[0].id}`);
}
}
function handleShare() {
showOverflowMenu = false;
haptics.tap();
toast.show("Share coming soon!", "info");
}
function handleViewQueue() {
showOverflowMenu = false;
haptics.tap();
toast.show("Queue view coming soon!", "info");
}
</script>
{#if shouldShow && displayMedia}
<div class="{className || 'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]">
<!-- Remote Mode Indicator -->
{#if $isRemoteMode && $selectedSession}
<div class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
<span class="text-xs text-[var(--color-jellyfin)] font-medium">
Playing on {$selectedSession.deviceName}
</span>
</div>
{/if}
<!-- Progress bar (clickable for scrubbing) -->
<button
onclick={handleSeek}
class="h-1 bg-gray-700 w-full cursor-pointer hover:h-2 transition-all relative group"
aria-label="Seek"
>
<div
class="h-full bg-[var(--color-jellyfin)] transition-all duration-100 pointer-events-none"
style="width: {progress}%"
></div>
<!-- Hover indicator -->
<div class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</button>
<div
class="px-4 py-3 flex items-center gap-4 touch-pan-y relative"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
>
<!-- Media info -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Artwork (clickable to expand) -->
<button
onclick={onExpand}
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
aria-label="Open full player"
>
{#if displayMedia?.primaryImageTag}
<CachedImage
itemId={displayMedia.id}
imageType="Primary"
tag={displayMedia.primaryImageTag}
maxWidth={100}
alt={displayMedia?.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-6 h-6" 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}
</button>
<!-- Title & Artist -->
<div class="flex-1 min-w-0">
<button
onclick={onExpand}
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
>
{displayMedia?.name}
</button>
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
<button
onclick={(e) => navigateToArtist(e, artist.id)}
class="hover:text-white hover:underline transition-colors"
>
{artist.name}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
{/each}
{:else if displayMedia?.artists?.length}
<span>{displayMedia?.artists.join(", ")}</span>
{/if}
{#if displayMedia?.albumId && displayMedia?.albumName}
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
<span class="text-gray-500"></span>
{/if}
<button
onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors"
>
{displayMedia?.albumName}
</button>
{:else if displayMedia?.albumName}
<span>{displayMedia?.albumName}</span>
{/if}
</div>
</div>
</div>
<!-- Favorite Button -->
{#if displayMedia}
<div class="hidden sm:block">
<FavoriteButton
itemId={displayMedia?.id ?? ""}
isFavorite={displayMedia?.userData?.isFavorite ?? false}
size="sm"
/>
</div>
{/if}
<!-- Cast Button (visible on all screen sizes) -->
<CastButton size="sm" />
<!-- Sleep Timer Indicator -->
<SleepTimerIndicator onClick={onSleepTimerClick} />
<!-- Volume Control (Linux only) -->
<div class="hidden sm:block">
<VolumeControl size="sm" />
</div>
<!-- Time -->
<div class="text-xs text-gray-400 hidden sm:block">
{formatTime(displayPosition)} / {formatTime(displayDuration)}
</div>
<!-- Controls -->
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
{onSleepTimerClick}
/>
<!-- Overflow Menu Button -->
<div class="relative">
<button
onclick={() => {
showOverflowMenu = !showOverflowMenu;
haptics.tap();
}}
class="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="More options"
>
<svg class="w-5 h-5 text-gray-400" 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>
<!-- Overflow Menu Dropdown -->
{#if showOverflowMenu}
<div
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
role="menu"
>
<button
onclick={handleViewQueue}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" 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>
View Queue
</button>
{#if displayMedia?.albumId}
<button
onclick={handleGoToAlbum}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
Go to Album
</button>
{/if}
{#if displayMedia?.artistItems?.length}
<button
onclick={handleGoToArtist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" 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>
Go to Artist
</button>
{/if}
<button
onclick={handleAddToPlaylist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add to Playlist
</button>
<button
onclick={handleShare}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Share
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Click outside to close overflow menu -->
{#if showOverflowMenu}
<button
class="fixed inset-0 z-[65]"
onclick={() => showOverflowMenu = false}
aria-label="Close menu"
></button>
{/if}
@@ -0,0 +1,170 @@
<script lang="ts">
import {
nextEpisode,
isNextEpisodePopupVisible,
nextEpisodeItem,
countdownSeconds,
initialCountdownSeconds,
isCountdownActive,
} from "$lib/stores/nextEpisode";
import {
cancelAutoPlay,
watchNextManually,
} from "$lib/services/nextEpisodeService";
import { auth } from "$lib/stores/auth";
import CachedImage from "../common/CachedImage.svelte";
// Use series primary image for better visual consistency
const imageId = $derived($nextEpisodeItem ? ($nextEpisodeItem.seriesId || $nextEpisodeItem.id) : null);
// Format episode info (S1:E5)
const episodeInfo = $derived.by(() => {
const episode = $nextEpisodeItem;
if (!episode) return "";
const season = episode.parentIndexNumber;
const epNum = episode.indexNumber;
if (season !== undefined && epNum !== undefined) {
return `S${season}:E${epNum}`;
}
return "";
});
// Calculate progress for the countdown bar (1 to 0)
const countdownProgress = $derived.by(() => {
const initial = $initialCountdownSeconds;
const current = $countdownSeconds;
if (initial <= 0) return 0;
return current / initial;
});
function handlePlayNow() {
if ($nextEpisodeItem) {
watchNextManually($nextEpisodeItem);
}
}
function handleCancel() {
cancelAutoPlay();
}
// Note: Countdown pause/resume on hover is not implemented
// Backend controls countdown timing via CountdownTick events
function handleMouseEnter() {
// TODO: Could add visual feedback on hover
}
function handleMouseLeave() {
// TODO: Could remove visual feedback
}
</script>
{#if $isNextEpisodePopupVisible && $nextEpisodeItem}
<div
class="fixed bottom-24 right-6 z-50 max-w-sm animate-slide-up"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="dialog"
aria-label="Next episode"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl shadow-2xl overflow-hidden border border-gray-700"
>
<!-- Episode Card -->
<div class="flex gap-4 p-4">
<!-- Thumbnail -->
<div
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
>
{#if imageId && $nextEpisodeItem.primaryImageTag}
<CachedImage
itemId={imageId}
imageType="Primary"
tag={$nextEpisodeItem.primaryImageTag}
maxHeight={200}
alt={$nextEpisodeItem.name}
class="w-full h-full object-cover"
/>
{/if}
<!-- Play icon overlay -->
<div
class="absolute inset-0 flex items-center justify-center bg-black/30"
>
<svg
class="w-8 h-8 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<!-- Episode Info -->
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-400 mb-1">Up Next</p>
<h3 class="text-sm font-semibold text-white truncate">
{$nextEpisodeItem.name}
</h3>
<p class="text-xs text-gray-400">
{$nextEpisodeItem.seriesName}
{episodeInfo}
</p>
</div>
</div>
<!-- Actions -->
<div class="px-4 pb-4 flex gap-3">
<!-- Cancel button -->
<button
onclick={handleCancel}
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium text-white transition-colors"
>
Cancel
</button>
<!-- Play now button with countdown -->
<button
onclick={handlePlayNow}
class="flex-1 px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg text-sm font-medium text-white transition-colors relative overflow-hidden"
>
{#if $isCountdownActive}
<!-- Countdown progress bar -->
<div
class="absolute inset-0 bg-white/20 origin-left transition-transform duration-1000 ease-linear"
style="transform: scaleX({countdownProgress})"
></div>
{/if}
<span class="relative">
{#if $isCountdownActive}
Play in {$countdownSeconds}s
{:else}
Play Now
{/if}
</span>
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out forwards;
}
</style>
+236
View File
@@ -0,0 +1,236 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { queue } from "$lib/stores/queue";
import CachedImage from "../common/CachedImage.svelte";
interface Props {
items: MediaItem[];
currentIndex?: number | null;
onItemClick?: (index: number) => void;
onClose?: () => void;
}
let {
items,
currentIndex = null,
onItemClick,
onClose,
}: Props = $props();
// Add unique IDs for dnd-zone (required)
interface DndItem extends MediaItem {
dndId: string;
}
let dndItems = $derived<DndItem[]>(
items.map((item, index) => ({
...item,
dndId: `${item.id}-${index}`,
}))
);
let dragDisabled = $state(true);
const flipDurationMs = 200;
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 handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
const { items: newItems, info } = e.detail;
// Update local state during drag
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
dragDisabled = true;
}
}
async function handleFinalize(e: CustomEvent<{ items: DndItem[]; info: { source: string } }>) {
const { items: newItems, info } = e.detail;
// Find the moved item by comparing old and new positions
const oldIds = dndItems.map(i => i.dndId);
const newIds = newItems.map(i => i.dndId);
// Find indices that changed
let fromIndex = -1;
let toIndex = -1;
for (let i = 0; i < oldIds.length; i++) {
if (oldIds[i] !== newIds[i]) {
if (fromIndex === -1) {
// Find where the item at this position came from
fromIndex = oldIds.indexOf(newIds[i]);
toIndex = i;
}
break;
}
}
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
try {
// Optimistic update
queue.moveInQueue(fromIndex, toIndex);
// Sync with backend
await invoke("player_move_in_queue", {
fromIndex,
toIndex,
});
} catch (e) {
console.error("Failed to move queue item:", e);
// The store already updated optimistically, refresh if needed
}
}
if (info.source === SOURCES.POINTER) {
dragDisabled = true;
}
}
function startDrag(e: Event) {
e.preventDefault();
dragDisabled = false;
}
function handleKeyDown(e: KeyboardEvent) {
if ((e.key === "Enter" || e.key === " ") && dragDisabled) {
dragDisabled = false;
}
}
async function handleRemove(e: Event, index: number) {
e.stopPropagation();
try {
queue.removeFromQueue(index);
await invoke("player_remove_from_queue", { index });
} catch (err) {
console.error("Failed to remove from queue:", err);
}
}
</script>
<div class="bg-[var(--color-surface)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">Queue ({items.length})</h2>
<button
onclick={onClose}
class="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
aria-label="Close queue"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="max-h-96 overflow-y-auto">
{#if items.length === 0}
<div class="p-8 text-center text-gray-400">
<p>Queue is empty</p>
</div>
{:else}
<ul
use:dndzone={{
items: dndItems,
flipDurationMs,
dragDisabled,
dropTargetStyle: {},
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="list-none p-0 m-0"
>
{#each dndItems as item, index (item.dndId)}
<li class="outline-none">
<div
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex === index ? 'bg-white/10' : ''}"
>
<!-- Drag handle -->
<button
type="button"
aria-label="Drag to reorder"
class="p-1 cursor-grab touch-none text-gray-500 hover:text-white transition-colors"
onmousedown={startDrag}
ontouchstart={startDrag}
onkeydown={handleKeyDown}
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"/>
</svg>
</button>
<!-- Clickable area for track selection -->
<button
type="button"
onclick={() => onItemClick?.(index)}
class="flex-1 flex items-center gap-3 text-left min-w-0"
aria-label="Play {item.name}"
>
<!-- Index or playing indicator -->
<div class="w-6 text-center flex-shrink-0">
{#if currentIndex === index}
<svg class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{:else}
<span class="text-sm text-gray-500">{index + 1}</span>
{/if}
</div>
<!-- Artwork -->
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
{#if item.primaryImageTag}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
maxWidth={80}
alt={item.name}
class="w-full h-full object-cover"
/>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
{item.name}
</p>
{#if item.artists?.length}
<p class="text-xs text-gray-400 truncate">
{item.artists.join(", ")}
</p>
{/if}
</div>
<!-- Duration -->
<span class="text-xs text-gray-500 flex-shrink-0">
{formatDuration(item.runTimeTicks)}
</span>
</button>
<!-- Remove button -->
<button
type="button"
onclick={(e) => handleRemove(e, index)}
class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-white/5 transition-colors"
aria-label="Remove from queue"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import {
sleepTimerActive,
sleepTimerMode,
sleepTimerRemainingSeconds,
} from "$lib/stores/sleepTimer";
import { formatTime } from "$lib/utils/playbackUnits";
interface Props {
onClick?: () => void;
}
let { onClick }: Props = $props();
function getDisplayText(): string {
const mode = $sleepTimerMode;
switch (mode.kind) {
case "time":
return formatTime($sleepTimerRemainingSeconds);
case "endOfTrack":
return "End";
case "episodes":
return `${mode.remaining} ep`;
default:
return "";
}
}
</script>
{#if $sleepTimerActive}
<button
onclick={onClick}
class="flex items-center gap-1 px-2 py-1 rounded-full bg-[var(--color-jellyfin)]/20 text-[var(--color-jellyfin)] text-xs font-medium hover:bg-[var(--color-jellyfin)]/30 transition-colors"
title="Sleep timer active - click to modify"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
<span>{getDisplayText()}</span>
</button>
{/if}
@@ -0,0 +1,213 @@
<script lang="ts">
import {
sleepTimer,
sleepTimerMode,
sleepTimerActive,
} from "$lib/stores/sleepTimer";
import { currentQueueItem } from "$lib/stores/queue";
interface Props {
isOpen?: boolean;
onClose?: () => void;
}
let { isOpen = false, onClose }: Props = $props();
const timePresets = [15, 30, 45, 60];
const episodePresets = [1, 2, 3];
const isEpisode = $derived($currentQueueItem?.type === "Episode");
const isVideo = $derived(
$currentQueueItem?.type === "Episode" || $currentQueueItem?.type === "Movie"
);
function handleTimePreset(minutes: number) {
sleepTimer.setTimeTimer(minutes);
onClose?.();
}
function handleEndOfTrack() {
sleepTimer.setEndOfTrackTimer();
onClose?.();
}
function handleEpisodePreset(count: number) {
sleepTimer.setEpisodesTimer(count);
onClose?.();
}
function handleCancel() {
sleepTimer.cancel();
onClose?.();
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && onClose) {
onClose();
}
}
function getActiveLabel(): string {
const mode = $sleepTimerMode;
switch (mode.kind) {
case "time":
return "Timer active";
case "endOfTrack":
return "Stops after current";
case "episodes":
return `${mode.remaining} episode${mode.remaining !== 1 ? "s" : ""} remaining`;
default:
return "";
}
}
function getEndOfTrackLabel(): string {
const type = $currentQueueItem?.type;
if (type === "Episode") return "End of current episode";
if (type === "Movie") return "End of current film";
return "End of current track";
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
role="dialog"
aria-modal="true"
aria-labelledby="sleep-timer-title"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[80vh] sm:max-h-[70vh] flex flex-col shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="none"
>
<!-- Header -->
<div
class="px-6 py-4 border-b border-gray-800 flex items-center justify-between"
>
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">
Sleep Timer
</h2>
<button
onclick={onClose}
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4">
<!-- Active timer indicator -->
{#if $sleepTimerActive}
<div
class="mb-4 p-4 rounded-lg bg-[var(--color-jellyfin)]/10 border border-[var(--color-jellyfin)]/30"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
<span class="text-sm font-medium text-[var(--color-jellyfin)]">
{getActiveLabel()}
</span>
</div>
<button
onclick={handleCancel}
class="text-xs text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Time presets -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
<div class="grid grid-cols-2 gap-2">
{#each timePresets as minutes}
<button
onclick={() => handleTimePreset(minutes)}
class="p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-center"
>
<span class="text-lg font-medium text-white">{minutes}</span>
<span class="text-sm text-gray-400 ml-1">min</span>
</button>
{/each}
</div>
</div>
<!-- End of current track/episode/film -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after current</h3>
<button
onclick={handleEndOfTrack}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
<span class="text-white">{getEndOfTrackLabel()}</span>
</button>
</div>
<!-- Episode countdown (only for TV episodes) -->
{#if isEpisode}
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">
Stop after episodes
</h3>
<div class="space-y-2">
{#each episodePresets as count}
<button
onclick={() => handleEpisodePreset(count)}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
/>
</svg>
<span class="text-white"
>{count} more episode{count !== 1 ? "s" : ""}</span
>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,463 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock video element for testing seek behavior
function createMockVideoElement(options: {
paused?: boolean;
autoplay?: boolean;
currentTime?: number;
} = {}) {
const listeners: Record<string, (() => void)[]> = {};
return {
paused: options.paused ?? true,
autoplay: options.autoplay ?? true,
currentTime: options.currentTime ?? 0,
pause: vi.fn(function(this: any) {
this.paused = true;
}),
play: vi.fn(function(this: any) {
this.paused = false;
return Promise.resolve();
}),
addEventListener: vi.fn((event: string, handler: () => void) => {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(handler);
}),
removeEventListener: vi.fn((event: string, handler: () => void) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter(h => h !== handler);
}
}),
// Helper to trigger events in tests
_triggerEvent: (event: string) => {
listeners[event]?.forEach(h => h());
},
_getListeners: () => listeners,
};
}
describe("VideoPlayer Resume Logic", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("handleCanPlay seek behavior", () => {
it("should pause video before seeking to prevent autoplay from starting at position 0", async () => {
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
// Simulate the handleCanPlay logic
const initialPosition = 60;
const hasPerformedInitialSeek = false;
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
const wasPlaying = !videoElement.paused;
videoElement.pause();
expect(videoElement.pause).toHaveBeenCalled();
expect(wasPlaying).toBe(true);
}
});
it("should set currentTime to initial position", async () => {
const videoElement = createMockVideoElement();
const initialPosition = 120;
videoElement.currentTime = initialPosition;
expect(videoElement.currentTime).toBe(120);
});
it("should wait for seeked event before resuming playback", async () => {
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
const initialPosition = 60;
// Simulate handleCanPlay logic
videoElement.pause();
videoElement.currentTime = initialPosition;
// Create the promise that waits for seeked
const seekPromise = new Promise<void>((resolve) => {
const onSeeked = () => {
videoElement.removeEventListener("seeked", onSeeked);
resolve();
};
videoElement.addEventListener("seeked", onSeeked);
});
// Verify listener was added
expect(videoElement.addEventListener).toHaveBeenCalledWith("seeked", expect.any(Function));
// Simulate seek completion
videoElement._triggerEvent("seeked");
await seekPromise;
// Verify listener was removed after seek
expect(videoElement.removeEventListener).toHaveBeenCalledWith("seeked", expect.any(Function));
});
it("should resume playback after seek completes when autoplay is enabled", async () => {
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
const initialPosition = 60;
// Simulate handleCanPlay logic
const wasPlaying = !videoElement.paused;
videoElement.pause();
videoElement.currentTime = initialPosition;
// Wait for seeked
const seekPromise = new Promise<void>((resolve) => {
const onSeeked = () => {
videoElement.removeEventListener("seeked", onSeeked);
resolve();
};
videoElement.addEventListener("seeked", onSeeked);
});
videoElement._triggerEvent("seeked");
await seekPromise;
// Resume playback
if (wasPlaying || videoElement.autoplay) {
await videoElement.play();
}
expect(videoElement.play).toHaveBeenCalled();
});
it("should not resume playback if video was paused and has no autoplay", async () => {
const videoElement = createMockVideoElement({ paused: true, autoplay: false });
const initialPosition = 60;
const wasPlaying = !videoElement.paused;
videoElement.pause();
videoElement.currentTime = initialPosition;
// Resume playback check
if (wasPlaying || videoElement.autoplay) {
await videoElement.play();
}
expect(videoElement.play).not.toHaveBeenCalled();
});
it("should have fallback timeout in case seeked event doesn't fire", async () => {
const videoElement = createMockVideoElement();
const initialPosition = 60;
videoElement.currentTime = initialPosition;
let resolved = false;
const seekPromise = new Promise<void>((resolve) => {
const onSeeked = () => {
videoElement.removeEventListener("seeked", onSeeked);
resolve();
};
videoElement.addEventListener("seeked", onSeeked);
// Fallback timeout
setTimeout(() => {
videoElement.removeEventListener("seeked", onSeeked);
resolved = true;
resolve();
}, 2000);
});
// Don't trigger seeked event - rely on timeout
vi.advanceTimersByTime(2000);
await seekPromise;
expect(resolved).toBe(true);
});
it("should not seek if initialPosition is 0", () => {
const videoElement = createMockVideoElement();
const initialPosition = 0;
const hasPerformedInitialSeek = false;
let seekPerformed = false;
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
seekPerformed = true;
}
expect(seekPerformed).toBe(false);
});
it("should not seek if hasPerformedInitialSeek is true", () => {
const videoElement = createMockVideoElement();
const initialPosition = 60;
const hasPerformedInitialSeek = true;
let seekPerformed = false;
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
seekPerformed = true;
}
expect(seekPerformed).toBe(false);
});
it("should not seek if videoElement is null", () => {
const videoElement = null;
const initialPosition = 60;
const hasPerformedInitialSeek = false;
let seekPerformed = false;
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
seekPerformed = true;
}
expect(seekPerformed).toBe(false);
});
});
describe("hasPerformedInitialSeek flag", () => {
it("should be set to true after seek is initiated", () => {
const videoElement = createMockVideoElement();
const initialPosition = 60;
let hasPerformedInitialSeek = false;
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
hasPerformedInitialSeek = true;
videoElement.currentTime = initialPosition;
}
expect(hasPerformedInitialSeek).toBe(true);
});
it("should be reset to false when streamUrl changes", () => {
let hasPerformedInitialSeek = true;
let currentStreamUrl = "url1";
// Simulate $effect when streamUrl changes
const newStreamUrl = "url2";
if (newStreamUrl !== currentStreamUrl) {
currentStreamUrl = newStreamUrl;
hasPerformedInitialSeek = false;
}
expect(hasPerformedInitialSeek).toBe(false);
});
it("should prevent duplicate seeks on multiple canplay events", () => {
const videoElement = createMockVideoElement();
const initialPosition = 60;
let hasPerformedInitialSeek = false;
let seekCount = 0;
// First canplay
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
hasPerformedInitialSeek = true;
videoElement.currentTime = initialPosition;
seekCount++;
}
// Second canplay (shouldn't seek)
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
hasPerformedInitialSeek = true;
videoElement.currentTime = initialPosition;
seekCount++;
}
expect(seekCount).toBe(1);
});
});
describe("initialPosition change handling", () => {
it("should seek when initialPosition changes after initial seek was done", () => {
const videoElement = createMockVideoElement();
let hasPerformedInitialSeek = true;
const isMediaReady = true;
let currentTime = 60;
// Simulate new position
const newPosition = 120;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
hasPerformedInitialSeek = false;
videoElement.currentTime = newPosition;
currentTime = newPosition;
}
expect(videoElement.currentTime).toBe(120);
expect(currentTime).toBe(120);
expect(hasPerformedInitialSeek).toBe(false);
});
it("should not seek if media is not ready", () => {
const videoElement = createMockVideoElement();
const hasPerformedInitialSeek = true;
const isMediaReady = false;
const newPosition = 120;
let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
seekTriggered = true;
}
expect(seekTriggered).toBe(false);
});
it("should not seek if initial seek hasn't been performed yet", () => {
const videoElement = createMockVideoElement();
const hasPerformedInitialSeek = false;
const isMediaReady = true;
const newPosition = 120;
let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
seekTriggered = true;
}
expect(seekTriggered).toBe(false);
});
});
describe("seekOffset handling for transcoded streams", () => {
it("should reset seekOffset to 0 when streamUrl changes", () => {
let seekOffset = 120;
let currentStreamUrl = "url1";
// Simulate $effect when streamUrl changes
const newStreamUrl = "url2";
if (newStreamUrl !== currentStreamUrl) {
currentStreamUrl = newStreamUrl;
seekOffset = 0;
}
expect(seekOffset).toBe(0);
});
it("should add seekOffset to currentTime for transcoded streams", () => {
const seekOffset = 60;
const videoElementTime = 30; // Video thinks it's at 30s
const currentTime = seekOffset + videoElementTime;
expect(currentTime).toBe(90); // Actual position is 90s
});
});
describe("error handling", () => {
it("should handle seek errors gracefully", async () => {
const videoElement = createMockVideoElement();
const initialPosition = 60;
let errorCaught = false;
// Simulate a video element that throws on currentTime set
Object.defineProperty(videoElement, 'currentTime', {
set: () => { throw new Error('Seek not allowed'); },
get: () => 0,
});
try {
videoElement.currentTime = initialPosition;
} catch (err) {
errorCaught = true;
}
expect(errorCaught).toBe(true);
});
it("should handle play() rejection gracefully", async () => {
const videoElement = createMockVideoElement();
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
let errorCaught = false;
try {
await videoElement.play();
} catch (err) {
errorCaught = true;
}
expect(errorCaught).toBe(true);
});
});
});
describe("Resume Dialog Logic", () => {
describe("progress eligibility", () => {
it("should show resume dialog when watched > 30 seconds and < 90% complete", () => {
const positionSeconds = 60;
const totalSeconds = 3600; // 1 hour video
const progressPercent = (positionSeconds / totalSeconds) * 100;
const shouldShow = positionSeconds > 30 && progressPercent < 90;
expect(shouldShow).toBe(true);
});
it("should not show resume dialog when watched <= 30 seconds", () => {
const positionSeconds = 25;
const totalSeconds = 3600;
const progressPercent = (positionSeconds / totalSeconds) * 100;
const shouldShow = positionSeconds > 30 && progressPercent < 90;
expect(shouldShow).toBe(false);
});
it("should not show resume dialog when >= 90% complete", () => {
const positionSeconds = 3300; // 55 minutes of 1 hour video
const totalSeconds = 3600;
const progressPercent = (positionSeconds / totalSeconds) * 100;
const shouldShow = positionSeconds > 30 && progressPercent < 90;
expect(shouldShow).toBe(false);
});
it("should handle edge case at exactly 30 seconds", () => {
const positionSeconds = 30;
const totalSeconds = 3600;
const progressPercent = (positionSeconds / totalSeconds) * 100;
const shouldShow = positionSeconds > 30 && progressPercent < 90;
expect(shouldShow).toBe(false); // > 30, not >= 30
});
it("should handle edge case at exactly 90%", () => {
const positionSeconds = 3240; // Exactly 90% of 3600
const totalSeconds = 3600;
const progressPercent = (positionSeconds / totalSeconds) * 100;
const shouldShow = positionSeconds > 30 && progressPercent < 90;
expect(shouldShow).toBe(false); // < 90, not <= 90
});
});
describe("position tick conversion", () => {
it("should convert ticks to seconds correctly", () => {
const positionTicks = 600_000_000; // 60 seconds in ticks
const positionSeconds = positionTicks / 10_000_000;
expect(positionSeconds).toBe(60);
});
it("should convert seconds to ticks correctly", () => {
const positionSeconds = 120;
const positionTicks = positionSeconds * 10_000_000;
expect(positionTicks).toBe(1_200_000_000);
});
it("should handle large tick values", () => {
const positionTicks = 36_000_000_000; // 1 hour in ticks
const positionSeconds = positionTicks / 10_000_000;
expect(positionSeconds).toBe(3600);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { volume, isMuted } from "$lib/stores/player";
import { platform } from "@tauri-apps/plugin-os";
interface Props {
size?: "sm" | "md" | "lg";
}
let { size = "md" }: Props = $props();
// On Android, volume is controlled by system volume buttons (not a slider)
const isAndroid = platform() === "android";
let showSlider = $state(false);
let sliderValue = $state($volume);
// Sync slider with store value
$effect(() => {
sliderValue = $volume;
});
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const newVolume = parseFloat(target.value);
sliderValue = newVolume;
await invoke("player_set_volume", { volume: newVolume });
}
async function toggleMute() {
await invoke("player_toggle_mute");
}
function toggleSlider() {
showSlider = !showSlider;
}
// Icon sizes based on prop (use $derived for reactivity)
const iconSize = $derived(size === "sm" ? "w-4 h-4" : size === "md" ? "w-5 h-5" : "w-6 h-6");
const buttonPadding = $derived(size === "sm" ? "p-1" : size === "md" ? "p-2" : "p-3");
</script>
{#if !isAndroid}
<div class="relative flex items-center gap-1">
<!-- Volume Icon Button (click to toggle slider) -->
<button
onclick={toggleSlider}
class="{buttonPadding} rounded-full hover:bg-white/10 transition-colors"
title="Volume"
>
{#if $isMuted || sliderValue === 0}
<!-- Muted Icon -->
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
/>
</svg>
{:else if sliderValue < 0.33}
<!-- Low Volume Icon -->
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.536 8.464a5 5 0 010 7.072m-9.95-9.193L4 8.929V5.071a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586z"
/>
</svg>
{:else if sliderValue < 0.66}
<!-- Medium Volume Icon -->
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.536 8.464a5 5 0 010 7.072M6.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h2.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L6.586 15z"
/>
</svg>
{:else}
<!-- High Volume Icon -->
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
</svg>
{/if}
</button>
<!-- Volume Slider (toggle on click) -->
{#if showSlider}
<div
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
role="group"
aria-label="Volume controls"
>
<!-- Mute button inside slider popup -->
<button
onclick={toggleMute}
class="p-1 rounded hover:bg-white/10 transition-colors"
title={$isMuted ? "Unmute" : "Mute"}
>
{#if $isMuted || sliderValue === 0}
<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="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
/>
</svg>
{:else}
<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="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
</svg>
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={sliderValue}
oninput={handleVolumeChange}
class="w-24 h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/>
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sliderValue * 100)}%</span>
</div>
{/if}
</div>
{/if}
<!-- Click outside to close volume slider -->
{#if showSlider}
<button
class="fixed inset-0 z-[65]"
onclick={() => showSlider = false}
aria-label="Close volume"
></button>
{/if}
@@ -0,0 +1,154 @@
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import TrackList from "$lib/components/library/TrackList.svelte";
interface Props {
results: MediaItem[];
loading?: boolean;
onItemClick?: (item: MediaItem) => void;
}
let { results, loading = false, onItemClick }: Props = $props();
// Categorize results by type
const categorized = $derived({
music: {
tracks: results.filter((i) => i.type === "Audio"),
albums: results.filter((i) => i.type === "MusicAlbum"),
artists: results.filter((i) => i.type === "MusicArtist"),
},
movies: results.filter((i) => i.type === "Movie"),
tvShows: results.filter((i) => i.type === "Series" || i.type === "Episode"),
});
const hasMusic = $derived(
categorized.music.tracks.length > 0 ||
categorized.music.albums.length > 0 ||
categorized.music.artists.length > 0
);
const hasAnyResults = $derived(
hasMusic || categorized.movies.length > 0 || categorized.tvShows.length > 0
);
</script>
{#if loading}
<div class="flex justify-center py-12">
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !hasAnyResults}
<div class="text-center py-12 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<p>No results found</p>
</div>
{:else}
<div class="space-y-8">
<!-- Music Section -->
{#if hasMusic}
<div class="space-y-6">
<h2 class="text-2xl font-semibold text-white px-4">Music</h2>
<!-- Tracks Subsection -->
{#if categorized.music.tracks.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Tracks ({categorized.music.tracks.length})
</h3>
<TrackList tracks={categorized.music.tracks} />
</div>
{/if}
<!-- Albums Subsection -->
{#if categorized.music.albums.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Albums ({categorized.music.albums.length})
</h3>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.music.albums as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
<!-- Artists Subsection -->
{#if categorized.music.artists.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Artists ({categorized.music.artists.length})
</h3>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.music.artists as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={false}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Movies Section -->
{#if categorized.movies.length > 0}
<div>
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
Movies ({categorized.movies.length})
</h2>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.movies as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
<!-- TV Shows Section -->
{#if categorized.tvShows.length > 0}
<div>
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
TV Shows ({categorized.tvShows.length})
</h2>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.tvShows as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
</style>
@@ -0,0 +1,100 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import SessionPickerModal from "./SessionPickerModal.svelte";
interface Props {
size?: "sm" | "md" | "lg";
className?: string;
}
let { size = "md", className = "" }: Props = $props();
let showPicker = $state(false);
// Size classes
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const buttonSizeClasses = {
sm: "p-1.5",
md: "p-2",
lg: "p-2.5",
};
function handleClick() {
showPicker = true;
}
function closePicker() {
showPicker = false;
}
// Set polling hints when component mounts/unmounts
onMount(async () => {
// Initial manual refresh to get sessions
sessions.refresh();
// Set initial hint based on connection state
const hint = isConnected ? "cast_active" : "cast_discovery";
await invoke("sessions_set_polling_hint", { hint });
});
onDestroy(async () => {
// Reset to normal polling when component unmounts
await invoke("sessions_set_polling_hint", { hint: "normal" });
});
// Update polling hint when connection state changes
$effect(() => {
const hint = isConnected ? "cast_active" : "cast_discovery";
invoke("sessions_set_polling_hint", { hint });
});
const isConnected = $derived($selectedSession !== null);
const sessionCount = $derived($controllableSessions.length);
</script>
<button
onclick={handleClick}
class="{buttonSizeClasses[size]} {className} rounded-lg transition-colors relative {isConnected
? 'text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/10'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
title={isConnected
? `Casting to ${$selectedSession?.deviceName}`
: sessionCount > 0
? `Cast to ${sessionCount} available device${sessionCount !== 1 ? 's' : ''}`
: 'No devices available'}
aria-label="Cast"
>
<!-- Cast Icon -->
<svg class={sizeClasses[size]} fill="currentColor" viewBox="0 0 24 24">
{#if isConnected}
<!-- Connected cast icon -->
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
{:else}
<!-- Standard cast icon -->
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z" />
{/if}
</svg>
<!-- Badge for available sessions count -->
{#if !isConnected && sessionCount > 0}
<span
class="absolute -top-1 -right-1 w-4 h-4 bg-[var(--color-jellyfin)] text-white text-[10px] font-bold rounded-full flex items-center justify-center"
>
{sessionCount}
</span>
{/if}
<!-- Connected indicator -->
{#if isConnected}
<span class="absolute bottom-0 right-0 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full border-2 border-[var(--color-surface)]"></span>
{/if}
</button>
<SessionPickerModal isOpen={showPicker} onClose={closePicker} />
@@ -0,0 +1,240 @@
<script lang="ts">
import type { Session } from "$lib/api/types";
import { sessions } from "$lib/stores";
interface Props {
session: Session;
}
let { session }: Props = $props();
let isCommandPending = $state(false);
const playState = $derived(session.playState);
const nowPlaying = $derived(session.nowPlayingItem);
const supportsSeek = $derived(playState?.canSeek ?? false);
const supportsNextPrevious = $derived(
session.supportedCommands.includes("NextTrack") &&
session.supportedCommands.includes("PreviousTrack")
);
async function handlePlayPause() {
if (isCommandPending) return;
isCommandPending = true;
try {
await sessions.sendPlayPause(session.id);
} finally {
isCommandPending = false;
}
}
async function handleStop() {
if (isCommandPending) return;
isCommandPending = true;
try {
await sessions.sendStop(session.id);
} finally {
isCommandPending = false;
}
}
async function handleNext() {
if (isCommandPending || !supportsNextPrevious) return;
isCommandPending = true;
try {
await sessions.sendNext(session.id);
} finally {
isCommandPending = false;
}
}
async function handlePrevious() {
if (isCommandPending || !supportsNextPrevious) return;
isCommandPending = true;
try {
await sessions.sendPrevious(session.id);
} finally {
isCommandPending = false;
}
}
function handleVolumeChange(event: Event) {
const target = event.target as HTMLInputElement;
const volume = parseInt(target.value);
sessions.sendVolume(session.id, volume);
}
function handleSeek(event: Event) {
const target = event.target as HTMLInputElement;
const positionPercent = parseFloat(target.value);
if (nowPlaying?.runTimeTicks) {
const positionTicks = (positionPercent / 100) * nowPlaying.runTimeTicks;
sessions.sendSeek(session.id, positionTicks);
}
}
function formatTime(ticks: number): string {
const seconds = Math.floor(ticks / 10000000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
const positionPercent = $derived(() => {
if (!playState?.positionTicks || !nowPlaying?.runTimeTicks) return 0;
return (playState.positionTicks / nowPlaying.runTimeTicks) * 100;
});
</script>
<div class="flex flex-col gap-6 p-6 rounded-lg bg-[var(--color-surface)]">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-white">Remote Control</h3>
<p class="text-sm text-gray-400">Controlling: {session.deviceName}</p>
</div>
</div>
{#if nowPlaying && playState}
<!-- Now Playing Info -->
<div class="flex flex-col gap-2">
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName}
<p class="text-sm text-gray-400 truncate">{nowPlaying.albumName}</p>
{/if}
</div>
<!-- Seek Bar -->
{#if supportsSeek && nowPlaying.runTimeTicks}
<div class="flex flex-col gap-2">
<input
type="range"
min="0"
max="100"
step="0.1"
value={positionPercent()}
oninput={handleSeek}
class="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>{playState.positionTicks ? formatTime(playState.positionTicks) : "0:00"}</span>
<span>{formatTime(nowPlaying.runTimeTicks)}</span>
</div>
</div>
{/if}
<!-- Playback Controls -->
<div class="flex items-center justify-center gap-4">
<!-- Previous -->
<button
onclick={handlePrevious}
disabled={!supportsNextPrevious || isCommandPending}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Previous"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<!-- Play/Pause -->
<button
onclick={handlePlayPause}
disabled={isCommandPending}
class="p-3 rounded-full bg-white text-black hover:scale-105 transition-transform disabled:opacity-50"
title={playState.isPaused ? "Play" : "Pause"}
>
{#if playState.isPaused}
<svg class="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{:else}
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{/if}
</button>
<!-- Next -->
<button
onclick={handleNext}
disabled={!supportsNextPrevious || isCommandPending}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Next"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
<!-- Stop -->
<button
onclick={handleStop}
disabled={isCommandPending}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-50 transition-colors"
title="Stop"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
</button>
</div>
<!-- Volume Control -->
{#if playState.volumeLevel !== undefined}
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
{#if playState.isMuted || playState.volumeLevel === 0}
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
{:else if playState.volumeLevel < 50}
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
{:else}
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
{/if}
</svg>
<input
type="range"
min="0"
max="100"
value={playState.volumeLevel}
oninput={handleVolumeChange}
class="flex-1 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0"
/>
<span class="text-sm text-gray-400 w-12 text-right">{playState.volumeLevel}%</span>
</div>
{/if}
{:else}
<!-- No media playing -->
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="w-16 h-16 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
<h4 class="text-base font-medium text-gray-400 mb-2">No Media Playing</h4>
<p class="text-sm text-gray-500">
Start playing media on {session.deviceName} to control it from here
</p>
</div>
{/if}
</div>
@@ -0,0 +1,114 @@
<script lang="ts">
import type { Session } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
interface Props {
session: Session;
selected?: boolean;
onclick?: () => void;
}
let { session, selected = false, onclick }: Props = $props();
function getImageUrl(): string {
if (!session.nowPlayingItem) return "";
try {
const repo = auth.getRepository();
return repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
maxWidth: 80,
tag: session.nowPlayingItem.primaryImageTag,
});
} catch {
return "";
}
}
function formatTime(ticks: number): string {
const seconds = Math.floor(ticks / 10000000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
const imageUrl = $derived(getImageUrl());
const playState = $derived(session.playState);
const nowPlaying = $derived(session.nowPlayingItem);
</script>
<button
type="button"
onclick={onclick}
class="w-full p-4 rounded-lg border-2 transition-all text-left
{selected
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
: 'border-[var(--color-surface)] bg-[var(--color-surface)] hover:border-[var(--color-jellyfin)]/50'}"
>
<!-- Session header -->
<div class="flex items-start gap-3 mb-2">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-white truncate">{session.deviceName}</h3>
<p class="text-sm text-gray-400 truncate">{session.client}{session.userName}</p>
</div>
{#if selected}
<div class="flex-shrink-0 w-2 h-2 rounded-full bg-[var(--color-jellyfin)]"></div>
{/if}
</div>
<!-- Now playing -->
{#if nowPlaying && playState}
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
{#if imageUrl}
<img
src={imageUrl}
alt={nowPlaying.name}
class="w-12 h-12 rounded object-cover flex-shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName}
<p class="text-xs text-gray-400 truncate">{nowPlaying.albumName}</p>
{/if}
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1">
{#if playState.isPaused}
<svg class="w-3 h-3 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
<span class="text-xs text-gray-400">Paused</span>
{:else}
<svg class="w-3 h-3 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<span class="text-xs text-[var(--color-jellyfin)]">Playing</span>
{/if}
</div>
{#if playState.positionTicks}
<span class="text-xs text-gray-500"></span>
<span class="text-xs text-gray-400">{formatTime(playState.positionTicks)}</span>
{/if}
{#if playState.volumeLevel !== undefined}
<span class="text-xs text-gray-500"></span>
<span class="text-xs text-gray-400">Vol {playState.volumeLevel}%</span>
{/if}
</div>
</div>
</div>
{:else}
<div class="mt-3 pt-3 border-t border-white/10">
<p class="text-sm text-gray-500 italic">No media playing</p>
</div>
{/if}
</button>
@@ -0,0 +1,317 @@
<script lang="ts">
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
import type { Session } from "$lib/api/types";
interface Props {
isOpen?: boolean;
onClose?: () => void;
onSelectSession?: (session: Session) => void;
}
let { isOpen = false, onClose, onSelectSession }: Props = $props();
async function handleSessionSelect(session: Session) {
try {
// Transfer playback to remote session
await playbackMode.transferToRemote(session.id);
if (onSelectSession) {
onSelectSession(session);
}
if (onClose) {
onClose();
}
} catch (error) {
console.error("Failed to select session:", error);
// Error is already stored in playbackMode store
}
}
async function handleTransferToLocal() {
try {
await playbackMode.transferToLocal();
if (onClose) {
onClose();
}
} catch (error) {
console.error("Failed to transfer to local:", error);
// Error is already stored in playbackMode store
}
}
async function handleDisconnect() {
try {
await playbackMode.disconnect();
if (onClose) {
onClose();
}
} catch (error) {
console.error("Failed to disconnect:", error);
// Error is already stored in playbackMode store
}
}
function handleClearError() {
playbackMode.clearError();
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && onClose) {
onClose();
}
}
function getSessionIcon(client: string): string {
const clientLower = client.toLowerCase();
if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) {
return "tv";
} else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) {
return "web";
} else if (clientLower.includes("mobile") || clientLower.includes("ios") || clientLower.includes("android")) {
return "phone";
}
return "device";
}
$effect(() => {
if (isOpen) {
sessions.refresh();
}
});
</script>
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
role="dialog"
aria-modal="true"
aria-labelledby="session-picker-title"
tabindex="-1"
>
<!-- Modal -->
<div
class="bg-[var(--color-surface)] rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[80vh] sm:max-h-[70vh] flex flex-col shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="none"
>
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
<h2 id="session-picker-title" class="text-lg font-semibold text-white">
Cast to Device
</h2>
<button
onclick={onClose}
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto">
{#if $sessions.isLoading && $controllableSessions.length === 0}
<!-- Loading -->
<div class="flex items-center justify-center py-12">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-4 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<p class="text-sm text-gray-400">Searching for devices...</p>
</div>
</div>
{:else if $controllableSessions.length > 0}
<!-- Sessions list -->
<div class="p-4 space-y-2">
{#if $selectedSession}
<!-- Currently connected session -->
<div class="mb-4 p-4 rounded-lg bg-[var(--color-jellyfin)]/10 border border-[var(--color-jellyfin)]/30">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-[var(--color-jellyfin)] uppercase">Connected</span>
<button
onclick={handleDisconnect}
class="text-xs text-gray-400 hover:text-white transition-colors"
>
Disconnect
</button>
</div>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-[var(--color-jellyfin)]/20 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
{#if getSessionIcon($selectedSession.client) === "tv"}
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
{:else if getSessionIcon($selectedSession.client) === "web"}
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
{:else if getSessionIcon($selectedSession.client) === "phone"}
<path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z" />
{:else}
<path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" />
{/if}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-white truncate">{$selectedSession.deviceName}</h3>
<p class="text-sm text-gray-400 truncate">{$selectedSession.client}</p>
</div>
</div>
</div>
{/if}
<!-- Available sessions -->
{#each $controllableSessions as session (session.id)}
{#if session.id !== $selectedSession?.id}
<button
onclick={() => handleSessionSelect(session)}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
{#if getSessionIcon(session.client) === "tv"}
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
{:else if getSessionIcon(session.client) === "web"}
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
{:else if getSessionIcon(session.client) === "phone"}
<path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z" />
{:else}
<path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" />
{/if}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
<p class="text-sm text-gray-400 truncate">{session.client}</p>
{#if session.nowPlayingItem}
<p class="text-xs text-gray-500 truncate mt-1">
Playing: {session.nowPlayingItem.name}
</p>
{/if}
</div>
{#if session.playState}
<div class="flex-shrink-0">
{#if session.playState.isPaused}
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</div>
{/if}
</div>
</button>
{/if}
{/each}
</div>
{:else}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-12 px-6 text-center">
<svg class="w-16 h-16 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<h3 class="text-base font-medium text-gray-400 mb-2">No Devices Found</h3>
<p class="text-sm text-gray-500 mb-4">
Start playing media on another Jellyfin client to cast to it from here.
</p>
<button
onclick={() => sessions.refresh()}
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white text-sm font-medium hover:bg-[var(--color-jellyfin-hover)] transition-colors"
>
Refresh
</button>
</div>
{/if}
</div>
<!-- Footer -->
{#if $controllableSessions.length > 0}
<div class="px-6 py-3 border-t border-gray-800">
<!-- Play Locally Button (shown when remote session is active) -->
{#if $selectedSession && $playbackMode.mode === "remote"}
<button
onclick={handleTransferToLocal}
class="w-full mb-3 p-3 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium hover:bg-[var(--color-jellyfin-hover)] transition-colors flex items-center justify-center gap-2"
disabled={$isTransferring}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" />
</svg>
Play Locally
</button>
{/if}
<div class="flex items-center justify-between">
<button
onclick={() => sessions.refresh()}
class="text-sm text-gray-400 hover:text-white 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Refresh
</button>
<span class="text-xs text-gray-500">
{$controllableSessions.length} device{$controllableSessions.length !== 1 ? "s" : ""} available
</span>
</div>
</div>
{/if}
<!-- Transfer Progress Overlay -->
{#if $isTransferring}
<div class="absolute inset-0 bg-black/70 flex items-center justify-center z-10 rounded-2xl">
<div class="bg-[var(--color-surface)] p-6 rounded-lg flex flex-col items-center gap-4">
<div class="w-10 h-10 border-4 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<p class="text-white font-medium">Transferring playback...</p>
<button
onclick={() => {
playbackMode.cancelTransfer();
if (onClose) onClose();
}}
class="px-4 py-2 rounded-lg bg-gray-700 text-white text-sm font-medium hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Error Display -->
{#if $transferError}
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
<span class="text-sm">{$transferError}</span>
</div>
<button
onclick={handleClearError}
class="text-white hover:text-gray-200 transition-colors"
aria-label="Dismiss error"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,98 @@
<script lang="ts">
import { sessions, controllableSessions } from "$lib/stores";
import SessionCard from "./SessionCard.svelte";
interface Props {
onSelectSession?: (sessionId: string) => void;
}
let { onSelectSession }: Props = $props();
function handleSessionClick(sessionId: string) {
if (onSelectSession) {
onSelectSession(sessionId);
}
}
</script>
<div class="flex flex-col gap-3">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">
Active Sessions
{#if $controllableSessions.length > 0}
<span class="text-sm text-gray-400 font-normal">
({$controllableSessions.length})
</span>
{/if}
</h2>
<button
onclick={() => sessions.refresh()}
class="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
title="Refresh sessions"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
<!-- Loading state -->
{#if $sessions.isLoading && $sessions.sessions.length === 0}
<div class="flex items-center justify-center py-12">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-4 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<p class="text-sm text-gray-400">Loading sessions...</p>
</div>
</div>
{/if}
<!-- Error state -->
{#if $sessions.error}
<div class="p-4 rounded-lg bg-red-500/10 border border-red-500/50">
<p class="text-sm text-red-400">{$sessions.error}</p>
</div>
{/if}
<!-- Sessions list -->
{#if $controllableSessions.length > 0}
<div class="flex flex-col gap-2">
{#each $controllableSessions as session (session.id)}
<SessionCard
{session}
selected={$sessions.selectedSessionId === session.id}
onclick={() => handleSessionClick(session.id)}
/>
{/each}
</div>
{:else if !$sessions.isLoading}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="w-16 h-16 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-400 mb-2">No Active Sessions</h3>
<p class="text-sm text-gray-500 max-w-sm">
No controllable Jellyfin sessions found. Start playing media on another device to control it from here.
</p>
</div>
{/if}
<!-- Last updated -->
{#if $sessions.lastUpdated && $controllableSessions.length > 0}
<p class="text-xs text-gray-500 text-center">
Last updated: {$sessions.lastUpdated.toLocaleTimeString()}
</p>
{/if}
</div>