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
+290
View File
@@ -0,0 +1,290 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { invoke } from "@tauri-apps/api/core";
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
let { children } = $props();
let searchQuery = $state("");
let showFullPlayer = $state(false);
let showOverflowMenu = $state(false);
let showSleepTimerModal = $state(false);
let shuffle = $state(false);
let repeat = $state<"off" | "all" | "one">("off");
let hasNext = $state(false);
let hasPrevious = $state(false);
let isAndroid = $state(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let failedAttempts = 0;
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
onMount(async () => {
// Detect platform
try {
const platformName = await platform();
isAndroid = platformName === "android";
} catch (err) {
console.error("Platform detection failed:", err);
}
// Poll for queue status (shuffle, repeat, hasNext, hasPrevious)
// Position/duration come from player events (pushed every 250ms)
// Add a small delay before starting polling to ensure player is ready on Android
const timeoutId = setTimeout(() => {
updateQueueStatus(); // Initial update
pollInterval = setInterval(updateQueueStatus, 1000);
}, 100);
return () => {
clearTimeout(timeoutId);
if (pollInterval) clearInterval(pollInterval);
};
});
// Redirect to login if not authenticated
$effect(() => {
if (!$isAuthLoading && !$isAuthenticated) {
goto("/");
}
});
async function updateQueueStatus() {
try {
const queue = await invoke<{
items: any[];
currentIndex: number | null;
hasNext: boolean;
hasPrevious: boolean;
shuffle: boolean;
repeat: string;
}>("player_get_queue");
// Reset failure counter on success
failedAttempts = 0;
hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious;
shuffle = queue.shuffle;
repeat = queue.repeat as "off" | "all" | "one";
} catch (e) {
failedAttempts++;
// Only log errors after initial attempts (player may still be initializing)
if (failedAttempts > MAX_SILENT_FAILURES) {
console.error("[Queue Status] Error:", e);
}
}
}
async function handleLogout() {
await auth.logout();
library.reset();
goto("/");
}
async function handleSearch(query: string) {
if (query.trim()) {
await library.search(query);
} else {
library.clearSearch();
}
}
</script>
{#if $isAuthLoading}
<div class="min-h-screen flex items-center justify-center">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if $isAuthenticated}
<div class="h-screen flex flex-col overflow-hidden">
<!-- Header -->
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
<div class="px-4 py-3 flex items-center gap-4">
<!-- Logo -->
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
JellyTau
</a>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-1">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Home
</a>
<a
href="/library"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Library
</a>
<a
href="/downloads"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Downloads
</a>
<a
href="/settings"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Settings
</a>
</nav>
<!-- Search (desktop only) -->
<div class="flex-1 max-w-md hidden md:block">
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
</div>
<!-- User menu -->
<div class="flex items-center gap-3">
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
<!-- Desktop: Downloads icon -->
<a
href="/downloads"
class="hidden md:block text-gray-400 hover:text-white transition-colors"
title="Downloads"
>
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<!-- Mobile: Overflow menu button -->
<div class="relative md:hidden">
<button
onclick={() => showOverflowMenu = !showOverflowMenu}
class="text-gray-400 hover:text-white transition-colors"
title="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>
<!-- Overflow menu dropdown -->
{#if showOverflowMenu}
<!-- Backdrop to close menu when clicking outside -->
<div
class="fixed inset-0 z-40"
onclick={() => showOverflowMenu = false}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showOverflowMenu = false; }}
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Menu -->
<div class="absolute right-0 top-full mt-2 w-48 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50">
<a
href="/downloads"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => showOverflowMenu = false}
>
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Downloads
</a>
<a
href="/settings"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => showOverflowMenu = false}
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
<div class="border-t border-gray-700 my-1"></div>
<button
onclick={() => { showOverflowMenu = false; handleLogout(); }}
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
{/if}
</div>
<!-- Desktop: Logout button -->
<button
onclick={handleLogout}
class="hidden md:block text-gray-400 hover:text-white transition-colors"
title="Sign out"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</header>
<!-- Main content (with padding for nav bar and mini player) -->
<main class="flex-1 overflow-y-auto p-4 pb-16 {$currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? (isAndroid ? 'pb-40' : 'pb-40 md:pb-24') : ''}">
{@render children()}
</main>
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
<!-- Hide on player page since full player is already there -->
{#if !isAndroid && !$page.url.pathname.startsWith('/player/')}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
{shuffle}
{repeat}
{hasNext}
{hasPrevious}
onExpand={() => showFullPlayer = true}
onSleepTimerClick={() => showSleepTimerModal = true}
/>
{/if}
<!-- Full Audio Player -->
{#if showFullPlayer}
<AudioPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
{shuffle}
{repeat}
{hasNext}
{hasPrevious}
onClose={() => {
showFullPlayer = false;
window.history.back();
}}
/>
{/if}
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => showSleepTimerModal = false}
/>
</div>
{/if}
+195
View File
@@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery);
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
onMount(async () => {
if ($libraries.length === 0) {
await library.loadLibraries();
}
hasLoadedOnce = true;
});
// Reload when server becomes reachable (handles cache-first timing issue)
$effect(() => {
const serverReachable = $isServerReachable;
if (serverReachable && !previousServerReachable && hasLoadedOnce) {
// Reload libraries when server becomes available
library.loadLibraries();
// Also reload current library items if viewing a library
if ($currentLibrary) {
library.loadItems($currentLibrary.id, {
genres: $selectedGenres.length > 0 ? $selectedGenres : undefined,
});
}
}
previousServerReachable = serverReachable;
});
async function handleLibraryClick(lib: Library) {
// Route to dedicated music library page
if (lib.collectionType === "music") {
library.setCurrentLibrary(lib);
goto("/library/music");
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadItems(lib.id);
}
async function handleGenreFilterChange() {
if ($currentLibrary) {
await library.loadItems($currentLibrary.id, {
genres: $selectedGenres.length > 0 ? $selectedGenres : undefined,
});
}
}
function handleItemClick(item: MediaItem | Library) {
if ("type" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
switch (mediaItem.type) {
case "Series":
case "Movie":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "CollectionFolder":
case "Playlist":
case "Channel":
case "ChannelFolderItem":
// Navigate to detail view
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
// Episodes play directly
goto(`/player/${mediaItem.id}`);
break;
default:
// For other items, try detail page first
goto(`/library/${mediaItem.id}`);
break;
}
} else {
// It's a Library
handleLibraryClick(item as Library);
}
}
function goBackToLibraries() {
library.setCurrentLibrary(null);
}
</script>
<div class="space-y-8">
{#if searchQuery}
<!-- Search results -->
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-white">
Search results for "{searchQuery}"
</h1>
<button
onclick={() => library.clearSearch()}
class="text-sm text-gray-400 hover:text-white"
>
Clear search
</button>
</div>
<LibraryGrid
items={searchResults}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else if $currentLibrary}
<!-- Library content -->
<div class="space-y-6">
<div class="flex items-center gap-4">
<button
onclick={goBackToLibraries}
class="text-gray-400 hover:text-white transition-colors"
aria-label="Back to libraries"
>
<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>
<h1 class="text-2xl font-bold text-white">{$currentLibrary.name}</h1>
</div>
{#if isMusicLibrary}
<GenreFilter onFilterChange={handleGenreFilterChange} />
{/if}
<LibraryGrid
items={$libraryItems}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else}
<!-- Libraries overview -->
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">Your Libraries</h1>
<button
onclick={() => goto('/settings')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Settings"
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
{#if $isLibraryLoading}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
<div class="aspect-video 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 $libraries.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No libraries found</p>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each $libraries as lib (lib.id)}
<MediaCard
item={lib}
size="medium"
onclick={() => handleLibraryClick(lib)}
/>
{/each}
</div>
{/if}
</div>
{/if}
</div>
+555
View File
@@ -0,0 +1,555 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core";
import type { MediaItem } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
import { downloads } from "$lib/stores/downloads";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import TrackList from "$lib/components/library/TrackList.svelte";
import AlbumDownloadButton from "$lib/components/library/AlbumDownloadButton.svelte";
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
let item = $state<MediaItem | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let seasonData = $state<SeasonData[]>([]);
let directFetchedEpisode = $state<MediaItem | null>(null);
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
const itemId = $derived($page.params.id);
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
onMount(async () => {
await loadItem();
hasLoadedOnce = true;
});
$effect(() => {
if (itemId) {
loadItem();
hasLoadedOnce = true;
}
});
// Reload when server becomes reachable (handles cache-first timing issue)
$effect(() => {
const serverReachable = $isServerReachable;
// If server just became reachable and we've already loaded, reload to get fresh data
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
loadItem();
}
previousServerReachable = serverReachable;
});
async function loadItem() {
loading = true;
error = null;
seasonData = [];
directFetchedEpisode = null;
try {
item = await library.loadItem(itemId);
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.type})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
item.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
// Set currentLibrary for music items if not already set
// This ensures navigation to music library pages works correctly
if ((item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Audio") && !$currentLibrary) {
// Find the music library
if ($libraries.length === 0) {
await library.loadLibraries();
}
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
if (musicLibrary) {
library.setCurrentLibrary(musicLibrary);
console.log("[LibraryDetail] Set current library to music library for music item");
}
}
await library.loadItems(itemId, { limit: 100 });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
if ((item?.type === "Movie" || item?.type === "Series" || item?.type === "Episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.type}...`);
try {
const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId);
console.log(`[LibraryDetail] - Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
if (fullItem.people && fullItem.people.length > 0) {
item = fullItem;
console.log(`[LibraryDetail] ✓ Updated item with ${fullItem.people.length} people`);
fullItem.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
} catch (e) {
console.warn(`Could not reload ${item?.type} with full cast data:`, e);
}
}
// For Series, load seasons and their episodes
if (item?.type === "Series") {
const seasons = $libraryItems.filter((i) => i.type === "Season");
const repo = auth.getRepository();
// Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items
.filter((i) => i.type === "Episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes };
});
seasonData = await Promise.all(seasonDataPromises);
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
if (episodeIdParam) {
const allEps = seasonData.flatMap((s) => s.episodes);
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
if (!foundInSeasons) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load item";
} finally {
loading = false;
}
}
// Images now handled by CachedImage component
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 handleItemClick(clickedItem: MediaItem) {
switch (clickedItem.type) {
case "Series":
case "Season":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "Playlist":
case "Channel":
case "ChannelFolderItem":
case "Episode":
case "Movie":
goto(`/library/${clickedItem.id}`);
break;
default:
goto(`/player/${clickedItem.id}`);
break;
}
}
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function handleEpisodeClick(episode: MediaItem) {
// Play the episode with the series queued for next episode
goto(`/player/${episode.id}`);
}
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.type === "Episode" || item?.type === "Movie") {
goto(`/player/${itemId}`);
} else if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const firstTrack = $libraryItems[0];
await invoke("player_play_album_track", {
repositoryHandle,
request: {
albumId: item.id,
albumName: item.name,
trackId: firstTrack.id,
shuffle: false,
},
});
} catch (e) {
console.error("Failed to play album:", e);
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if ($libraryItems.length > 0) {
// For other collections, start playing first item
goto(`/player/${$libraryItems[0].id}?queue=parent:${itemId}`);
}
}
async function handleShufflePlay() {
if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
// For albums, use the backend command with shuffle
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
// Pick a random track to start with
const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)];
await invoke("player_play_album_track", {
repositoryHandle,
request: {
albumId: item.id,
albumName: item.name,
trackId: randomTrack.id,
shuffle: true,
},
});
} catch (e) {
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
}
}
// For episode focus view: get all episodes across all seasons
const allEpisodes = $derived(
seasonData.flatMap((s) => s.episodes)
);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
? allEpisodes.find((e) => e.id === focusedEpisodeId) ?? directFetchedEpisode
: null
);
function handleBackToSeries() {
// Navigate to series page without the episode param
goto(`/library/${itemId}`);
}
</script>
<div class="relative">
<!-- Backdrop -->
{#if item?.backdropImageTags?.[0]}
<div class="absolute inset-0 -z-10 h-96 overflow-hidden">
<CachedImage
itemId={item.id}
imageType="Backdrop"
tag={item.backdropImageTags[0]}
maxWidth={1920}
class="w-full h-full object-cover opacity-30"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
</div>
{/if}
{#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 error}
<div class="text-center py-12">
<p class="text-red-400">{error}</p>
<button
onclick={() => goto("/library")}
class="mt-4 text-[var(--color-jellyfin)] hover:underline"
>
Back to library
</button>
</div>
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.type === "Person"}
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.type === "Series" && focusedEpisode}
<EpisodeFocusView
episode={focusedEpisode}
series={item}
{allEpisodes}
onBack={handleBackToSeries}
/>
{:else}
<div class="space-y-8">
<!-- Header with item info -->
<div class="flex gap-6 pt-4">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.primaryImageTag}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
maxWidth={400}
alt={item.name}
class="w-full rounded-lg shadow-lg"
/>
{:else}
<div class="w-full aspect-[2/3] 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="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>
<!-- Info -->
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)}
<p class="text-lg text-gray-400 mt-1">
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{:else if item.productionYear || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
{item.artists?.join(", ") || item.productionYear}
</p>
{/if}
</div>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-400">
{#if item.type}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span>
{/if}
{#if item.runTimeTicks}
<span>{formatDuration(item.runTimeTicks)}</span>
{/if}
{#if item.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>
{item.communityRating.toFixed(1)}
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
</button>
{#if item.type !== "Episode" && item.type !== "Movie"}
<button
onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<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>
Shuffle
</button>
{/if}
{#if item.type === "MusicAlbum"}
<AlbumDownloadButton
albumId={item.id}
albumName={item.name}
tracks={$libraryItems}
/>
{:else if item.type === "Series"}
<SeriesDownloadButton
seriesId={item.id}
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
{:else if item.type === "Movie"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={true}
size="lg"
/>
{:else if item.type === "Episode"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={false}
size="lg"
/>
{/if}
</div>
<!-- Overview -->
{#if item.overview}
<p class="text-gray-300 leading-relaxed max-w-2xl">{item.overview}</p>
{/if}
</div>
</div>
<!-- Crew Links - for Movies and Series -->
{#if item.people && (item.type === "Movie" || item.type === "Series")}
<div class="space-y-2">
{#if item.people.some(p => p.type === "Director")}
<CrewLinks
people={item.people}
roleFilter={["Director"]}
label="Directed by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Writer")}
<CrewLinks
people={item.people}
roleFilter={["Writer"]}
label="Written by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Composer")}
<CrewLinks
people={item.people}
roleFilter={["Composer"]}
label="Music by"
maxShow={2}
/>
{/if}
</div>
{/if}
<!-- Genre Tags -->
{#if item.genres?.length}
<div>
<GenreTags genres={item.genres} maxShow={6} />
</div>
{/if}
<!-- Cast Section - for Movies, Series, and Episodes -->
{#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length}
<CastSection people={item.people} />
{/if}
<!-- Related Items Section - for Movies and Series -->
{#if (item.type === "Movie" || item.type === "Series") && (item.genres?.length || item.people?.length)}
<RelatedItemsSection
currentItemId={item.id}
itemType={item.type}
genres={item.genres}
people={item.people}
limit={12}
/>
{/if}
<!-- Content items -->
<div>
{#if item.type === "MusicAlbum"}
<!-- Tracks in list view -->
<div class="space-y-8">
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">Tracks</h2>
<TrackList
tracks={$libraryItems}
loading={$isLibraryLoading}
showArtist={false}
showAlbum={false}
showDownload={true}
context={{ type: "album", albumId: item.id, albumName: item.name }}
/>
</div>
<!-- Related Albums -->
{#if item.genres?.length || item.artistItems?.length}
<RelatedItemsSection
currentItemId={item.id}
itemType="MusicAlbum"
genres={item.genres}
artistIds={item.artistItems?.map(a => a.id)}
limit={12}
/>
{/if}
</div>
{:else if item.type === "Series"}
<!-- Series: Seasons with episodes -->
<div class="space-y-8">
{#if $isLibraryLoading}
<div class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if seasonData.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No seasons found</p>
</div>
{:else}
{#each seasonData as { season, episodes } (season.id)}
<SeasonSection
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
onEpisodeClick={handleEpisodeClick}
/>
{/each}
{/if}
</div>
{:else if item.type === "MusicArtist"}
<!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} />
{:else}
<!-- Other content in grid view -->
<LibraryGrid
title="Contents"
items={$libraryItems}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
{/if}
</div>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* Movie genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Movie"],
title: "Movie Genres",
backPath: "/library",
genreIcon:
"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",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No movies found in this genre",
};
</script>
<GenericGenreBrowser {config} />
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { goto } from "$app/navigation";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const categories: Category[] = [
{
id: "tracks",
name: "Tracks",
icon: "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",
description: "All songs",
route: "/library/music/tracks",
},
{
id: "artists",
name: "Artists",
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
description: "Browse by artist",
route: "/library/music/artists",
},
{
id: "albums",
name: "Albums",
icon: "M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z",
description: "Browse by album",
route: "/library/music/albums",
},
{
id: "playlists",
name: "Playlists",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01",
description: "Your playlists",
route: "/library/music/playlists",
},
{
id: "genres",
name: "Genres",
icon: "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
description: "Browse by genre",
route: "/library/music/genres",
},
];
function handleCategoryClick(route: string) {
goto(route);
}
</script>
<div class="space-y-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Music Library</h1>
<p class="text-gray-400 mt-1">Choose a category to browse</p>
</div>
<button
onclick={() => goto('/library')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
>
<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>
</div>
<!-- Category Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
{#each categories as category (category.id)}
<button
onclick={() => handleCategoryClick(category.route)}
class="group relative bg-[var(--color-surface)] rounded-xl p-8 hover:bg-[var(--color-surface-hover)] transition-all duration-200 text-left overflow-hidden"
>
<!-- Background gradient -->
<div
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"
></div>
<!-- Content -->
<div class="relative z-10">
<!-- Icon -->
<div class="w-16 h-16 mb-4 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<!-- Text -->
<h2 class="text-2xl font-bold text-white mb-2 group-hover:text-[var(--color-jellyfin)] transition-colors">
{category.name}
</h2>
<p class="text-gray-400 text-sm">
{category.description}
</p>
<!-- Arrow indicator -->
<div class="mt-4 flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-sm font-medium mr-1">Browse</span>
<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="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
{/each}
</div>
</div>
@@ -0,0 +1,57 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import type { MediaItem } from "$lib/api/types";
/**
* Album browser
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums or artists...",
sortOptions: [
{
key: "name",
label: "A-Z",
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
},
{
key: "artist",
label: "Artist",
compareFn: (a: MediaItem, b: MediaItem) => {
const aArtist = a.artists?.[0] || "";
const bArtist = b.artists?.[0] || "";
return aArtist.localeCompare(bArtist);
},
},
{
key: "year",
label: "Year",
compareFn: (a: MediaItem, b: MediaItem) => {
const aYear = a.productionYear || 0;
const bYear = b.productionYear || 0;
return bYear - aYear;
},
},
{
key: "recent",
label: "Recent",
compareFn: (a: MediaItem, b: MediaItem) => {
const aDate = a.userData?.lastPlayedDate || "";
const bDate = b.userData?.lastPlayedDate || "";
return bDate.localeCompare(aDate);
},
},
],
defaultSort: "name",
displayComponent: "grid" as const,
searchFields: ["name", "artists"],
};
</script>
<GenericMediaListPage {config} />
@@ -0,0 +1,39 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import type { MediaItem } from "$lib/api/types";
/**
* Artist browser
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "MusicArtist",
title: "Artists",
backPath: "/library/music",
searchPlaceholder: "Search artists...",
sortOptions: [
{
key: "name",
label: "A-Z",
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
},
{
key: "recent",
label: "Recent",
compareFn: (a: MediaItem, b: MediaItem) => {
const aDate = a.userData?.lastPlayedDate || "";
const bDate = b.userData?.lastPlayedDate || "";
return bDate.localeCompare(aDate);
},
},
],
defaultSort: "name",
displayComponent: "grid" as const,
searchFields: ["name"],
};
</script>
<GenericMediaListPage {config} />
@@ -0,0 +1,23 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* Music album genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["MusicAlbum"],
title: "Genres",
backPath: "/library/music",
genreIcon:
"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",
itemDisplayMode: "square" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No albums found in this genre",
};
</script>
<GenericGenreBrowser {config} />
@@ -0,0 +1,23 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* Playlist browser
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Playlist",
title: "Playlists",
backPath: "/library/music",
searchPlaceholder: "Search playlists...",
sortOptions: [], // No sorting for playlists
defaultSort: "",
displayComponent: "grid" as const,
searchFields: ["name"],
};
</script>
<GenericMediaListPage {config} />
@@ -0,0 +1,57 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import type { MediaItem } from "$lib/api/types";
/**
* Track browser
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Audio",
title: "Tracks",
backPath: "/library/music",
searchPlaceholder: "Search tracks or artists...",
sortOptions: [
{
key: "title",
label: "Title",
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
},
{
key: "artist",
label: "Artist",
compareFn: (a: MediaItem, b: MediaItem) => {
const aArtist = a.artists?.[0] || "";
const bArtist = b.artists?.[0] || "";
return aArtist.localeCompare(bArtist);
},
},
{
key: "album",
label: "Album",
compareFn: (a: MediaItem, b: MediaItem) => {
const aAlbum = a.album || "";
const bAlbum = b.album || "";
return aAlbum.localeCompare(bAlbum);
},
},
{
key: "recent",
label: "Recent",
compareFn: (a: MediaItem, b: MediaItem) => {
const aDate = a.userData?.lastPlayedDate || "";
const bDate = b.userData?.lastPlayedDate || "";
return bDate.localeCompare(aDate);
},
},
],
defaultSort: "title",
displayComponent: "tracklist" as const,
searchFields: ["name", "artists", "album"],
};
</script>
<GenericMediaListPage {config} />
@@ -0,0 +1,23 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* TV show genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Series"],
title: "TV Genres",
backPath: "/library",
genreIcon:
"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",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No shows found in this genre",
};
</script>
<GenericGenreBrowser {config} />