First working POC
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "../app.css";
|
||||
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { connectivity, isConnected } from "$lib/stores/connectivity";
|
||||
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
|
||||
import { initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sessions } from "$lib/stores/sessions";
|
||||
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
|
||||
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
||||
import Toast from "$lib/components/Toast.svelte";
|
||||
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||
import BottomNav from "$lib/components/BottomNav.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
let isInitialized = $state(false);
|
||||
let pendingSyncCount = $state(0);
|
||||
let isAndroid = $state(false);
|
||||
let shuffle = $state(false);
|
||||
let repeat = $state<"off" | "all" | "one">("off");
|
||||
let hasNext = $state(false);
|
||||
let hasPrevious = $state(false);
|
||||
let showSleepTimerModal = $state(false);
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize auth state (restore session from secure storage)
|
||||
await auth.initialize();
|
||||
isInitialized = true;
|
||||
|
||||
// Detect platform (Android needs global mini player)
|
||||
try {
|
||||
const platformName = await platform();
|
||||
isAndroid = platformName === "android";
|
||||
} catch (err) {
|
||||
console.error("Platform detection failed:", err);
|
||||
}
|
||||
|
||||
// Initialize player event listener for push-based updates
|
||||
await initPlayerEvents();
|
||||
|
||||
// Initialize download event listener
|
||||
await initDownloadEvents();
|
||||
|
||||
// Start sync service for offline mutation queue
|
||||
syncService.start();
|
||||
|
||||
// Initialize playback mode and session monitoring
|
||||
playbackMode.initializeSessionMonitoring();
|
||||
await playbackMode.refresh();
|
||||
|
||||
// Poll for queue status (needed for mini player controls on all platforms)
|
||||
updateQueueStatus(); // Initial update
|
||||
pollInterval = setInterval(updateQueueStatus, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupPlayerEvents();
|
||||
cleanupDownloadEvents();
|
||||
connectivity.stopMonitoring();
|
||||
syncService.stop();
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
async function updateQueueStatus() {
|
||||
try {
|
||||
const queue = await invoke<{
|
||||
items: any[];
|
||||
currentIndex: number | null;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: string;
|
||||
}>("player_get_queue");
|
||||
|
||||
hasNext = queue.hasNext;
|
||||
hasPrevious = queue.hasPrevious;
|
||||
shuffle = queue.shuffle;
|
||||
repeat = queue.repeat as "off" | "all" | "one";
|
||||
} catch (e) {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity monitoring is now started early in auth.initialize()
|
||||
// This effect is kept only for when the user logs in during the session
|
||||
$effect(() => {
|
||||
if ($isAuthenticated) {
|
||||
// Check if monitoring is already running by attempting to get status
|
||||
// If not running, start it (handles login during current session)
|
||||
const session = auth.getCurrentSession();
|
||||
if (session?.serverUrl) {
|
||||
connectivity.forceCheck().catch(() => {
|
||||
// If check fails, monitoring might not be started yet, so start it
|
||||
connectivity.startMonitoring(session.serverUrl, {
|
||||
onServerReconnected: () => {
|
||||
// Retry session verification when server becomes reachable
|
||||
auth.retryVerification();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update pending sync count periodically
|
||||
$effect(() => {
|
||||
if ($isAuthenticated) {
|
||||
const updateCount = async () => {
|
||||
pendingSyncCount = await syncService.getPendingCount();
|
||||
};
|
||||
updateCount();
|
||||
// Update every 10 seconds
|
||||
const interval = setInterval(updateCount, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col">
|
||||
{#if isInitialized}
|
||||
<!-- Offline indicator banner -->
|
||||
{#if $isAuthenticated && !$isConnected}
|
||||
<div class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-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="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
||||
</svg>
|
||||
<span>You're offline. Some features may be limited.</span>
|
||||
{#if pendingSyncCount > 0}
|
||||
<span class="bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{pendingSyncCount} pending sync{pendingSyncCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<!-- Re-authentication modal -->
|
||||
<ReauthModal isOpen={$needsReauth} />
|
||||
|
||||
<!-- Toast notifications (global) -->
|
||||
<Toast />
|
||||
|
||||
<!-- Bottom Navigation (mobile only - show everywhere except player and login) -->
|
||||
{#if $isAuthenticated && !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
|
||||
<BottomNav />
|
||||
{/if}
|
||||
|
||||
<!-- Mini Player - show everywhere except on full player page and login -->
|
||||
<!-- Android: Show on all routes (except player/login) -->
|
||||
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
|
||||
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
|
||||
{#if isAndroid || !$page.url.pathname.startsWith('/library')}
|
||||
<MiniPlayer
|
||||
media={$currentMedia}
|
||||
isPlaying={$isPlaying}
|
||||
position={$playbackPosition}
|
||||
duration={$playbackDuration}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
{hasNext}
|
||||
{hasPrevious}
|
||||
onExpand={() => {
|
||||
// Navigate to player page when mini player is expanded
|
||||
if ($currentMedia) {
|
||||
goto(`/player/${$currentMedia.id}`);
|
||||
}
|
||||
}}
|
||||
onSleepTimerClick={() => showSleepTimerModal = true}
|
||||
/>
|
||||
|
||||
<!-- Sleep Timer Modal -->
|
||||
<SleepTimerModal
|
||||
isOpen={showSleepTimerModal}
|
||||
onClose={() => showSleepTimerModal = false}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-screen">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { home } from "$lib/stores/home";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { currentMedia } from "$lib/stores/player";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
// Track if we've done an initial load (plain variable, not reactive)
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
let isAndroid = $state(false);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
$effect(() => {
|
||||
if (!$isAuthenticated) {
|
||||
goto("/login");
|
||||
}
|
||||
});
|
||||
|
||||
// Load home sections when authenticated
|
||||
onMount(async () => {
|
||||
// Detect platform
|
||||
try {
|
||||
const platformName = await platform();
|
||||
isAndroid = platformName === "android";
|
||||
} catch (err) {
|
||||
console.error("Platform detection failed:", err);
|
||||
}
|
||||
|
||||
if ($isAuthenticated) {
|
||||
await home.loadHomeSections();
|
||||
hasLoadedOnce = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Reload when server becomes reachable (handles startup timing issue)
|
||||
$effect(() => {
|
||||
const serverReachable = $isServerReachable;
|
||||
|
||||
// If server just became reachable and we've already done initial load, reload to get fresh data
|
||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && $isAuthenticated) {
|
||||
home.loadHomeSections();
|
||||
}
|
||||
|
||||
// Update tracking (outside reactive context to avoid re-triggering)
|
||||
previousServerReachable = serverReachable;
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
switch (item.type) {
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Folder":
|
||||
case "Channel":
|
||||
case "ChannelFolderItem":
|
||||
goto(`/library/${item.id}`);
|
||||
break;
|
||||
default:
|
||||
goto(`/player/${item.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const heroItems = $derived($home.heroItems);
|
||||
const resumeItems = $derived($home.resumeItems);
|
||||
const nextUpItems = $derived($home.nextUpItems);
|
||||
const latestItems = $derived($home.latestItems);
|
||||
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
|
||||
const resumeMovies = $derived($home.resumeMovies);
|
||||
const isLoading = $derived($home.isLoading);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="h-screen flex justify-center items-center">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div class="space-y-8">
|
||||
<!-- Hero Banner -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
{/if}
|
||||
|
||||
<!-- Next Movie -->
|
||||
{#if resumeMovies.length > 0}
|
||||
<Carousel
|
||||
title="Next Movie"
|
||||
items={resumeMovies}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Next Episode -->
|
||||
{#if nextUpItems.length > 0}
|
||||
<Carousel
|
||||
title="Next Episode"
|
||||
items={nextUpItems}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Listened -->
|
||||
{#if recentlyPlayedAudio.length > 0}
|
||||
<Carousel
|
||||
title="Recently Listened"
|
||||
items={recentlyPlayedAudio}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Continue Watching -->
|
||||
{#if resumeItems.length > 0}
|
||||
<Carousel
|
||||
title="Continue Watching"
|
||||
items={resumeItems}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Added -->
|
||||
{#if latestItems.length > 0}
|
||||
<Carousel
|
||||
title="Recently Added"
|
||||
items={latestItems}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Access -->
|
||||
<div class="pt-4 px-4">
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="text-[var(--color-jellyfin)] hover:underline text-lg"
|
||||
>
|
||||
Browse all libraries →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,324 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { downloads, activeDownloads, completedDownloads, pendingDownloads, failedDownloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||
import StorageManagement from "$lib/components/downloads/StorageManagement.svelte";
|
||||
|
||||
type TabType = "active" | "completed";
|
||||
let activeTab = $state<TabType>("active");
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await loadDownloads();
|
||||
});
|
||||
|
||||
async function loadDownloads() {
|
||||
try {
|
||||
loading = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load downloads:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const activeDownloadsList = $derived($activeDownloads.concat($pendingDownloads));
|
||||
const completedDownloadsList = $derived($completedDownloads.concat($failedDownloads));
|
||||
|
||||
async function pauseAll() {
|
||||
for (const download of $activeDownloads) {
|
||||
if (download.status === "downloading") {
|
||||
try {
|
||||
await downloads.pause(download.id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to pause download ${download.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh to update UI with new states
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeAll() {
|
||||
for (const download of activeDownloadsList) {
|
||||
if (download.status === "paused" || download.status === "failed") {
|
||||
try {
|
||||
await downloads.resume(download.id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to resume download ${download.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh to update UI with new states
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCompleted() {
|
||||
for (const download of $completedDownloads) {
|
||||
try {
|
||||
await downloads.delete(download.id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete download ${download.id}:`, error);
|
||||
}
|
||||
}
|
||||
// Refresh to update UI
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearStale() {
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await invoke("clear_stale_downloads", { userId });
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear stale downloads:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Back to library"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Downloads</h1>
|
||||
<p class="text-gray-400">Manage your offline media downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadDownloads}
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Refresh downloads"
|
||||
>
|
||||
<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="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>
|
||||
|
||||
<!-- Storage Management -->
|
||||
<StorageManagement />
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-4 border-b border-gray-700">
|
||||
<button
|
||||
onclick={() => (activeTab = "active")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'active'
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
>
|
||||
Active
|
||||
{#if activeDownloadsList.length > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
|
||||
{activeDownloadsList.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if activeTab === "active"}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = "completed")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'completed'
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
>
|
||||
Completed
|
||||
{#if completedDownloadsList.length > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-green-500/20 text-green-400">
|
||||
{completedDownloadsList.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if activeTab === "completed"}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color coding legend -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-3 border border-gray-700">
|
||||
<div class="flex items-center gap-6 text-xs text-gray-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded bg-green-500/50"></div>
|
||||
<span><span class="text-green-400 font-medium">Green</span> = Downloads you chose</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded bg-blue-500/50"></div>
|
||||
<span><span class="text-blue-400 font-medium">Blue</span> = Auto-cached content</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>Loading downloads...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Bulk Actions -->
|
||||
{#if activeTab === "active" && activeDownloadsList.length > 0}
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={pauseAll}
|
||||
class="px-4 py-2 bg-[var(--color-surface)] text-white rounded-lg font-medium hover:bg-[var(--color-surface-hover)] transition-colors text-sm"
|
||||
>
|
||||
Pause All
|
||||
</button>
|
||||
<button
|
||||
onclick={resumeAll}
|
||||
class="px-4 py-2 bg-[var(--color-surface)] text-white rounded-lg font-medium hover:bg-[var(--color-surface-hover)] transition-colors text-sm"
|
||||
>
|
||||
Resume All
|
||||
</button>
|
||||
<button
|
||||
onclick={clearStale}
|
||||
class="px-4 py-2 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium hover:bg-yellow-500/30 transition-colors text-sm"
|
||||
title="Remove all pending, paused, and failed downloads"
|
||||
>
|
||||
Clear Stale
|
||||
</button>
|
||||
</div>
|
||||
{:else if activeTab === "completed" && completedDownloadsList.length > 0}
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={clearCompleted}
|
||||
class="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg font-medium hover:bg-red-500/30 transition-colors text-sm"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
<button
|
||||
onclick={async () => {
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
if (confirm('Delete ALL downloads (including completed)? This cannot be undone.')) {
|
||||
await invoke("delete_all_downloads", { userId });
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
class="px-4 py-2 bg-red-600/30 text-red-300 rounded-lg font-medium hover:bg-red-600/40 transition-colors text-sm border border-red-500/50"
|
||||
title="Delete all downloads and files"
|
||||
>
|
||||
Delete All Content
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Downloads List -->
|
||||
<div class="space-y-3">
|
||||
{#if activeTab === "active"}
|
||||
{#if activeDownloadsList.length === 0}
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-400 text-lg font-medium">No active downloads</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
Downloads you start will appear here
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each activeDownloadsList as download (download.id)}
|
||||
<DownloadItem {download} />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if completedDownloadsList.length === 0}
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-400 text-lg font-medium">No completed downloads</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
Finished downloads will appear here
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each completedDownloadsList as download (download.id)}
|
||||
<DownloadItem {download} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
{#if activeDownloadsList.length === 0 && completedDownloadsList.length === 0}
|
||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||
<div class="flex gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-300">
|
||||
<p class="font-semibold mb-1">Getting started with downloads:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
||||
<li>Look for the download icon next to tracks, albums, and playlists</li>
|
||||
<li>Downloaded media is available for offline playback</li>
|
||||
<li>Configure download settings in the Settings page</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, isAuthenticated, isLoading, authError } from "$lib/stores/auth";
|
||||
|
||||
let step = $state<"server" | "login">("server");
|
||||
let serverUrl = $state("");
|
||||
let serverName = $state("");
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let connecting = $state(false);
|
||||
let loggingIn = $state(false);
|
||||
let localError = $state<string | null>(null);
|
||||
|
||||
// Redirect to library if already authenticated
|
||||
$effect(() => {
|
||||
if ($isAuthenticated && !$isLoading) {
|
||||
goto("/");
|
||||
}
|
||||
});
|
||||
|
||||
async function handleConnectServer(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!serverUrl.trim()) return;
|
||||
|
||||
connecting = true;
|
||||
localError = null;
|
||||
|
||||
try {
|
||||
const info = await auth.connectToServer(serverUrl);
|
||||
serverName = info.name;
|
||||
serverUrl = info.normalizedUrl; // Use normalized URL with https://
|
||||
step = "login";
|
||||
} catch (error) {
|
||||
localError = error instanceof Error ? error.message : "Failed to connect to server";
|
||||
} finally {
|
||||
connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
|
||||
loggingIn = true;
|
||||
localError = null;
|
||||
|
||||
try {
|
||||
await auth.login(username, password, serverUrl, serverName);
|
||||
// Redirect will happen automatically via $effect
|
||||
} catch (error) {
|
||||
localError = error instanceof Error ? error.message : "Login failed";
|
||||
} finally {
|
||||
loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBackToServer() {
|
||||
step = "server";
|
||||
localError = null;
|
||||
auth.clearError();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo/Title -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-[var(--color-jellyfin)] mb-2">JellyTau</h1>
|
||||
<p class="text-gray-400">Connect to your Jellyfin server</p>
|
||||
</div>
|
||||
|
||||
{#if $isLoading}
|
||||
<!-- Loading state -->
|
||||
<div class="flex 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 step === "server"}
|
||||
<!-- Server connection form -->
|
||||
<form onsubmit={handleConnectServer} class="space-y-4">
|
||||
<div>
|
||||
<label for="server-url" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<input
|
||||
id="server-url"
|
||||
type="text"
|
||||
bind:value={serverUrl}
|
||||
placeholder="https://jellyfin.example.com"
|
||||
class="w-full px-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"
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if localError || $authError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{localError || $authError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={connecting || !serverUrl.trim()}
|
||||
class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if connecting}
|
||||
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Connecting...
|
||||
{:else}
|
||||
Connect
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Login form -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
onclick={goBackToServer}
|
||||
class="text-gray-400 hover:text-white text-sm flex items-center gap-1"
|
||||
>
|
||||
<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 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-[var(--color-jellyfin)] font-medium">{serverName}</p>
|
||||
<p class="text-gray-500 text-sm">{serverUrl}</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleLogin} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
placeholder="Enter your username"
|
||||
class="w-full px-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"
|
||||
disabled={loggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Enter your password"
|
||||
class="w-full px-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"
|
||||
disabled={loggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if localError || $authError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{localError || $authError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !username.trim()}
|
||||
class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if loggingIn}
|
||||
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,577 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||
import { playbackPosition, playbackDuration } from "$lib/stores/player";
|
||||
import { get } from "svelte/store";
|
||||
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
||||
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
|
||||
import {
|
||||
reportPlaybackStart,
|
||||
reportPlaybackProgress,
|
||||
reportPlaybackStopped,
|
||||
} from "$lib/services/playbackReporting";
|
||||
import { cleanup as cleanupNextEpisode, handleEpisodeEnded } from "$lib/services/nextEpisodeService";
|
||||
|
||||
const itemId = $derived($page.params.id);
|
||||
const queueParam = $derived($page.url.searchParams.get("queue"));
|
||||
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
|
||||
|
||||
// Derive playback context from URL query params
|
||||
const playbackContext = $derived.by(() => {
|
||||
if (!queueParam) {
|
||||
return { type: "single" as const, id: null };
|
||||
}
|
||||
|
||||
if (queueParam.startsWith("parent:")) {
|
||||
const parentId = queueParam.substring(7);
|
||||
return { type: "container" as const, id: parentId };
|
||||
}
|
||||
|
||||
return { type: "single" as const, id: null };
|
||||
});
|
||||
|
||||
// Use player store for position/duration - updated by event system
|
||||
const position = $derived($playbackPosition);
|
||||
const duration = $derived($playbackDuration);
|
||||
let isPlaying = $state(false);
|
||||
let shuffle = $state(false);
|
||||
let repeat = $state<"off" | "all" | "one">("off");
|
||||
let hasNext = $state(false);
|
||||
let hasPrevious = $state(false);
|
||||
let currentMedia = $state<MediaItem | null>(null);
|
||||
let streamUrl = $state<string | null>(null);
|
||||
let mediaSourceId = $state<string | null>(null);
|
||||
let isVideo = $state(false);
|
||||
let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
|
||||
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
|
||||
let isOfflinePlayback = $state(false); // Whether playing from local file
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let showResumeDialog = $state(false);
|
||||
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let loadedItemId: string | null = null;
|
||||
|
||||
// Handle next episode navigation event from popup
|
||||
function handlePlayNextEpisode(event: Event) {
|
||||
const customEvent = event as CustomEvent<{ episode: MediaItem }>;
|
||||
const episode = customEvent.detail.episode;
|
||||
if (episode) {
|
||||
goto(`/player/${episode.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Start position polling (only for audio via MPV backend)
|
||||
pollInterval = setInterval(updateStatus, 1000);
|
||||
|
||||
// Listen for next episode navigation events
|
||||
window.addEventListener("playNextEpisode", handlePlayNextEpisode);
|
||||
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
window.removeEventListener("playNextEpisode", handlePlayNextEpisode);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupNextEpisode();
|
||||
});
|
||||
|
||||
// Load when itemId changes (handles both initial load and navigation)
|
||||
$effect(() => {
|
||||
const id = itemId;
|
||||
if (id && id !== loadedItemId) {
|
||||
loadAndPlay(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update currentMedia when queue item changes (for skip/next/previous)
|
||||
// Only for audio content - video content uses direct loading and shouldn't be affected by audio queue
|
||||
$effect(() => {
|
||||
const queueItem = $currentQueueItem;
|
||||
const currentIsVideo = currentMedia?.type === "Movie" || currentMedia?.type === "Episode";
|
||||
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
|
||||
currentMedia = queueItem;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAndPlay(id: string, startPosition?: number) {
|
||||
loading = true;
|
||||
error = null;
|
||||
loadedItemId = id;
|
||||
let retrievedProgressSeconds: number | null = null;
|
||||
|
||||
try {
|
||||
console.log("loadAndPlay: Loading item", id);
|
||||
// Load item details
|
||||
const item = await library.loadItem(id);
|
||||
console.log("loadAndPlay: Loaded item", item.name, "type:", item.type);
|
||||
currentMedia = item;
|
||||
|
||||
// Check if this is a non-playable collection type that should be viewed in library instead
|
||||
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"];
|
||||
if (collectionTypes.includes(item.type)) {
|
||||
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
|
||||
goto(`/library/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is video content (Movie and Episode are video types)
|
||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||
|
||||
// When switching to video, stop audio playback and clear the queue
|
||||
// This prevents audio from continuing in the background and clears stale state
|
||||
if (isVideo) {
|
||||
try {
|
||||
await invoke("player_stop");
|
||||
queue.clear();
|
||||
console.log("loadAndPlay: Stopped audio backend for video playback");
|
||||
} catch (e) {
|
||||
// Ignore - player may not have been playing
|
||||
}
|
||||
}
|
||||
|
||||
// Check for saved progress if no start position specified
|
||||
const userId = auth.getUserId();
|
||||
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition);
|
||||
|
||||
if (!startPosition && userId) {
|
||||
try {
|
||||
const progress = await invoke<{ positionTicks: number } | null>(
|
||||
"storage_get_playback_progress",
|
||||
{ userId, itemId: id }
|
||||
);
|
||||
console.log("Resume check - retrieved progress:", progress);
|
||||
|
||||
if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
|
||||
const positionSeconds = progress.positionTicks / 10_000_000;
|
||||
const totalSeconds = item.runTimeTicks / 10_000_000;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
|
||||
|
||||
// Store for later use regardless of whether dialog is shown
|
||||
retrievedProgressSeconds = positionSeconds;
|
||||
|
||||
// Show resume dialog if watched > 30 seconds and < 90% complete
|
||||
if (positionSeconds > 30 && progressPercent < 90) {
|
||||
console.log("Resume check - SHOWING RESUME DIALOG");
|
||||
savedProgress = { positionSeconds, progressPercent };
|
||||
showResumeDialog = true;
|
||||
loading = false;
|
||||
return; // Wait for user decision
|
||||
} else {
|
||||
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
|
||||
}
|
||||
} else {
|
||||
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to check saved progress:", e);
|
||||
// Continue with normal playback
|
||||
}
|
||||
} else {
|
||||
console.log("Resume check - Skipped. Reason:", !userId ? "No userId" : "Has startPosition");
|
||||
}
|
||||
|
||||
// Check if this item is downloaded locally
|
||||
const downloadsState = get(downloads);
|
||||
const localDownload = Object.values(downloadsState.downloads).find(
|
||||
(d: DownloadInfo) => d.itemId === id && d.status === "completed"
|
||||
);
|
||||
|
||||
if (localDownload) {
|
||||
// Use local file for playback
|
||||
console.log("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
|
||||
isOfflinePlayback = true;
|
||||
|
||||
// Get the storage path and construct full file path
|
||||
const storagePath = await invoke<string>("storage_get_path");
|
||||
const fullPath = `${storagePath}/${localDownload.filePath}`;
|
||||
console.log("loadAndPlay: Full local path:", fullPath);
|
||||
|
||||
// Convert file path to asset URL that can be played in webview
|
||||
const localUrl = convertFileSrc(fullPath);
|
||||
console.log("loadAndPlay: Converted to asset URL:", localUrl);
|
||||
|
||||
if (isVideo) {
|
||||
// Local video files don't need transcoding and support native seeking
|
||||
streamUrl = localUrl;
|
||||
videoNeedsTranscoding = false;
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||
videoInitialPosition = effectivePosition;
|
||||
} else {
|
||||
// Local audio playback via MPV backend
|
||||
console.log("loadAndPlay: Using MPV backend for offline audio");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: null, // Local file may not have artwork
|
||||
mediaType: "audio",
|
||||
streamUrl: localUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
if (startPosition) {
|
||||
await invoke("player_seek", { position: startPosition });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Online playback - get playback info from server
|
||||
isOfflinePlayback = false;
|
||||
const repo = auth.getRepository();
|
||||
console.log("loadAndPlay: Getting playback info");
|
||||
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||
|
||||
if (isVideo) {
|
||||
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||
console.log("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
|
||||
mediaSourceId = playbackInfo.mediaSourceId;
|
||||
videoNeedsTranscoding = playbackInfo.needsTranscoding;
|
||||
|
||||
// Use the stream URL from playback info (already transcoded if needed)
|
||||
streamUrl = playbackInfo.streamUrl;
|
||||
console.log("loadAndPlay: Using stream URL:", streamUrl);
|
||||
|
||||
// Set initial position for video player to seek to after load
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
// For transcoded content, we need to request a new stream with StartTimeTicks
|
||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||
if (effectivePosition > 0) {
|
||||
if (videoNeedsTranscoding) {
|
||||
// For transcoded streams, get a new URL starting at the position
|
||||
console.log("loadAndPlay: Getting transcoded stream starting at:", effectivePosition);
|
||||
streamUrl = await repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, effectivePosition);
|
||||
} else {
|
||||
// For direct streams, we'll seek after load
|
||||
videoInitialPosition = effectivePosition;
|
||||
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
||||
}
|
||||
} else {
|
||||
videoInitialPosition = 0;
|
||||
}
|
||||
} else {
|
||||
// For audio, use MPV backend
|
||||
console.log("loadAndPlay: Using MPV backend for audio");
|
||||
|
||||
// Check if we have a queue parameter (e.g., queue=parent:albumId)
|
||||
const queueParamValue = queueParam;
|
||||
if (queueParamValue?.startsWith("parent:")) {
|
||||
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
|
||||
console.log("loadAndPlay: Loading queue from parent:", parentId);
|
||||
|
||||
// Fetch all tracks from the parent (album/playlist)
|
||||
const result = await repo.getItems(parentId, {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
limit: 500,
|
||||
});
|
||||
const audioTracks = result.items.filter(t => t.type === "Audio");
|
||||
|
||||
if (audioTracks.length > 0) {
|
||||
// Find the index of the current item in the tracks
|
||||
const startIndex = audioTracks.findIndex(t => t.id === id);
|
||||
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
|
||||
|
||||
console.log("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
|
||||
|
||||
// Build queue items with stream URLs
|
||||
// Add error handling and logging for each track
|
||||
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
|
||||
try {
|
||||
console.log(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
|
||||
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
||||
if (!trackStreamUrl) {
|
||||
console.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
|
||||
throw new Error(`Failed to get stream URL for ${t.name}`);
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.name,
|
||||
artist: t.artists?.join(", ") || null,
|
||||
album: t.albumName || null,
|
||||
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: t.primaryImageTag
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag })
|
||||
: null,
|
||||
mediaType: "audio",
|
||||
streamUrl: trackStreamUrl,
|
||||
jellyfinItemId: t.id,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
|
||||
throw e; // Re-throw to fail fast and show error to user
|
||||
}
|
||||
}));
|
||||
|
||||
// Use player_play_queue to set up the backend queue
|
||||
await invoke("player_play_queue", {
|
||||
request: {
|
||||
items: queueItems,
|
||||
startIndex: actualStartIndex,
|
||||
shuffle: shuffleParam,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
|
||||
} else {
|
||||
// Fallback to single item playback
|
||||
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
} else {
|
||||
// No queue parameter - single item playback
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
||||
}
|
||||
|
||||
// Seek to start position if provided
|
||||
if (startPosition) {
|
||||
await invoke("player_seek", { position: startPosition });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.error("loadAndPlay error:", e);
|
||||
// Show detailed error including the full error object
|
||||
if (e instanceof Error) {
|
||||
error = `${e.name}: ${e.message}`;
|
||||
} else {
|
||||
error = `Unknown error: ${JSON.stringify(e)}`;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResumeFromBeginning() {
|
||||
showResumeDialog = false;
|
||||
savedProgress = null;
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
loadAndPlay(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResumeFromSaved() {
|
||||
showResumeDialog = false;
|
||||
const position = savedProgress?.positionSeconds ?? 0;
|
||||
savedProgress = null;
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
loadAndPlay(id, position);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const status = await invoke<{
|
||||
state: { kind: string; position?: number; duration?: number };
|
||||
shuffle: boolean;
|
||||
repeat: string;
|
||||
}>("player_get_status");
|
||||
|
||||
if (status.state.kind === "playing" || status.state.kind === "paused") {
|
||||
isPlaying = status.state.kind === "playing";
|
||||
// Note: position/duration are now derived from player store (updated by events)
|
||||
}
|
||||
shuffle = status.shuffle;
|
||||
repeat = status.repeat as "off" | "all" | "one";
|
||||
|
||||
// Update queue status
|
||||
const queue = await invoke<{
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}>("player_get_queue");
|
||||
hasNext = queue.hasNext;
|
||||
hasPrevious = queue.hasPrevious;
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// Use browser history to go back to the previous page
|
||||
// This ensures users return to where they came from (album, series, search, etc.)
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Fallback to library if no history (e.g., direct URL access)
|
||||
goto("/library");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle video seeking by requesting a new stream URL starting at the given position.
|
||||
* Transcoded streams don't support native seeking, so we restart from a new position.
|
||||
*/
|
||||
async function handleVideoSeek(positionSeconds: number, audioStreamIndex?: number): Promise<string> {
|
||||
const repo = auth.getRepository();
|
||||
const id = itemId;
|
||||
if (!id) throw new Error("No item ID");
|
||||
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, positionSeconds, audioStreamIndex);
|
||||
}
|
||||
|
||||
// Playback reporting callbacks
|
||||
function handleReportStart(positionSeconds: number) {
|
||||
const id = itemId;
|
||||
const context = playbackContext; // playbackContext is a derived value, not a function
|
||||
if (id) {
|
||||
reportPlaybackStart(id, positionSeconds, context.type, context.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReportProgress(positionSeconds: number, isPaused: boolean) {
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
reportPlaybackProgress(id, positionSeconds, isPaused);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReportStop(positionSeconds: number) {
|
||||
const id = itemId;
|
||||
if (id) {
|
||||
reportPlaybackStopped(id, positionSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideoEnded() {
|
||||
// Call backend to handle autoplay decision (works on both Android and Linux)
|
||||
try {
|
||||
await invoke("player_on_playback_ended");
|
||||
} catch (e) {
|
||||
console.error("[VideoPlayer] Failed to handle playback ended:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showResumeDialog && savedProgress}
|
||||
<div class="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 max-w-md mx-4 shadow-xl">
|
||||
<h2 class="text-xl font-semibold mb-4">Resume Playback?</h2>
|
||||
<p class="text-gray-300 mb-2">
|
||||
You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo ? 'video' : 'audio'}.
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm mb-6">
|
||||
Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning?
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleResumeFromBeginning}
|
||||
class="flex-1 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Start from Beginning
|
||||
</button>
|
||||
<button
|
||||
onclick={handleResumeFromSaved}
|
||||
class="flex-1 px-4 py-3 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)] rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
|
||||
<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="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
|
||||
<div class="text-center max-w-lg">
|
||||
<p class="text-red-400 mb-4 text-lg font-semibold">Playback Error</p>
|
||||
<pre class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg"
|
||||
>
|
||||
Back to Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isVideo && streamUrl}
|
||||
<VideoPlayer
|
||||
media={currentMedia}
|
||||
{streamUrl}
|
||||
mediaSourceId={mediaSourceId}
|
||||
initialPosition={videoInitialPosition}
|
||||
needsTranscoding={videoNeedsTranscoding}
|
||||
onClose={handleClose}
|
||||
onSeek={handleVideoSeek}
|
||||
onReportStart={handleReportStart}
|
||||
onReportProgress={handleReportProgress}
|
||||
onReportStop={handleReportStop}
|
||||
onEnded={handleVideoEnded}
|
||||
/>
|
||||
<NextEpisodePopup />
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
media={currentMedia}
|
||||
{isPlaying}
|
||||
{position}
|
||||
{duration}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
{hasNext}
|
||||
{hasPrevious}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { library } from "$lib/stores/library";
|
||||
import { goto } from "$app/navigation";
|
||||
import Search from "$lib/components/Search.svelte";
|
||||
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
async function handleSearch(query: string) {
|
||||
if (query.trim()) {
|
||||
await library.search(query);
|
||||
} else {
|
||||
library.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
switch (item.type) {
|
||||
case "Audio":
|
||||
// Navigate to album page if available, otherwise detail page
|
||||
if (item.albumId) {
|
||||
goto(`/library/${item.albumId}`);
|
||||
} else {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
break;
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Series":
|
||||
case "Movie":
|
||||
// Navigate to detail page to show metadata and cast
|
||||
goto(`/library/${item.id}`);
|
||||
break;
|
||||
case "Episode":
|
||||
// Episodes play directly
|
||||
goto(`/player/${item.id}`);
|
||||
break;
|
||||
default:
|
||||
goto(`/library/${item.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="mb-6">
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
{#if searchQuery.trim()}
|
||||
<SearchResults
|
||||
results={$library.searchResults}
|
||||
loading={$library.isLoading}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-center text-gray-400 mt-12">
|
||||
<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>Search your entire library</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Find music, movies, shows, and more</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { sessions, selectedSession } from "$lib/stores";
|
||||
import SessionsList from "$lib/components/sessions/SessionsList.svelte";
|
||||
import RemoteControls from "$lib/components/sessions/RemoteControls.svelte";
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
// Toggle selection - if clicking the same session, deselect it
|
||||
if ($sessions.selectedSessionId === sessionId) {
|
||||
sessions.selectSession(null);
|
||||
} else {
|
||||
sessions.selectSession(sessionId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-[var(--color-background)] p-4 md:p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Page Header -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Remote Sessions</h1>
|
||||
<p class="text-gray-400">
|
||||
Control playback on other Jellyfin clients
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Sessions List Column -->
|
||||
<div class="order-1">
|
||||
<SessionsList onSelectSession={handleSelectSession} />
|
||||
</div>
|
||||
|
||||
<!-- Remote Controls Column -->
|
||||
<div class="order-2">
|
||||
{#if $selectedSession}
|
||||
<div class="sticky top-4">
|
||||
<RemoteControls session={$selectedSession} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Placeholder when no session selected -->
|
||||
<div class="flex flex-col items-center justify-center p-12 rounded-lg bg-[var(--color-surface)] text-center">
|
||||
<svg class="w-20 h-20 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="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-400 mb-2">Select a Session</h3>
|
||||
<p class="text-sm text-gray-500 max-w-sm">
|
||||
Choose a session from the list to control playback on that device
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-8 p-4 rounded-lg bg-[var(--color-surface)] border border-[var(--color-jellyfin)]/20">
|
||||
<h3 class="text-sm font-semibold text-white mb-2">How to use Remote Sessions</h3>
|
||||
<ul class="text-sm text-gray-400 space-y-1 list-disc list-inside">
|
||||
<li>Start playing media on another Jellyfin client (TV, web browser, mobile app)</li>
|
||||
<li>The session will appear in the list above automatically</li>
|
||||
<li>Click on a session to select it and view playback controls</li>
|
||||
<li>Use the controls to play, pause, skip tracks, adjust volume, and seek</li>
|
||||
<li>Sessions update automatically - click refresh for immediate updates</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar for better appearance */
|
||||
:global(html) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-jellyfin) var(--color-surface);
|
||||
}
|
||||
|
||||
:global(::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(::-webkit-scrollbar-track) {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
:global(::-webkit-scrollbar-thumb) {
|
||||
background: var(--color-jellyfin);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--color-jellyfin-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,600 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
getCacheStats,
|
||||
setCacheLimit,
|
||||
clearCache,
|
||||
formatBytes,
|
||||
gbToBytes,
|
||||
bytesToGb,
|
||||
type ImageCacheStats,
|
||||
} from "$lib/services/imageCache";
|
||||
|
||||
type VolumeLevel = "loud" | "normal" | "quiet";
|
||||
|
||||
interface AudioSettings {
|
||||
crossfadeDuration: number;
|
||||
gaplessPlayback: boolean;
|
||||
normalizeVolume: boolean;
|
||||
volumeLevel: VolumeLevel;
|
||||
}
|
||||
|
||||
interface VideoSettings {
|
||||
autoPlayNextEpisode: boolean;
|
||||
autoPlayCountdownSeconds: number;
|
||||
}
|
||||
|
||||
let settings = $state<AudioSettings>({
|
||||
crossfadeDuration: 0,
|
||||
gaplessPlayback: true,
|
||||
normalizeVolume: false,
|
||||
volumeLevel: "normal",
|
||||
});
|
||||
|
||||
let videoSettings = $state<VideoSettings>({
|
||||
autoPlayNextEpisode: true,
|
||||
autoPlayCountdownSeconds: 10,
|
||||
});
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let saveMessage = $state("");
|
||||
|
||||
// Image cache state
|
||||
let cacheStats = $state<ImageCacheStats | null>(null);
|
||||
let cacheLoading = $state(false);
|
||||
let clearingCache = $state(false);
|
||||
|
||||
// Cache limit options in bytes
|
||||
const cacheLimitOptions = [
|
||||
{ label: "500 MB", bytes: gbToBytes(0.5) },
|
||||
{ label: "1 GB", bytes: gbToBytes(1), default: true },
|
||||
{ label: "2 GB", bytes: gbToBytes(2) },
|
||||
{ label: "5 GB", bytes: gbToBytes(5) },
|
||||
{ label: "Unlimited", bytes: 0 },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
loading = true;
|
||||
const [audioResult, videoResult] = await Promise.all([
|
||||
invoke<AudioSettings>("player_get_audio_settings"),
|
||||
invoke<VideoSettings>("player_get_video_settings"),
|
||||
]);
|
||||
settings = audioResult;
|
||||
videoSettings = videoResult;
|
||||
// Load cache stats in parallel but don't block on it
|
||||
loadCacheStats();
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCacheStats() {
|
||||
try {
|
||||
cacheLoading = true;
|
||||
cacheStats = await getCacheStats();
|
||||
} catch (e) {
|
||||
console.error("Failed to load cache stats:", e);
|
||||
} finally {
|
||||
cacheLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCacheLimitChange(limitBytes: number) {
|
||||
try {
|
||||
await setCacheLimit(limitBytes);
|
||||
// Reload stats to reflect new limit
|
||||
await loadCacheStats();
|
||||
} catch (e) {
|
||||
console.error("Failed to set cache limit:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
try {
|
||||
clearingCache = true;
|
||||
await clearCache();
|
||||
await loadCacheStats();
|
||||
} catch (e) {
|
||||
console.error("Failed to clear cache:", e);
|
||||
} finally {
|
||||
clearingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current selected limit option
|
||||
function isCurrentLimit(optionBytes: number): boolean {
|
||||
if (!cacheStats) return false;
|
||||
// Unlimited is 0
|
||||
if (optionBytes === 0 && cacheStats.limitBytes === 0) return true;
|
||||
// Allow small tolerance for floating point
|
||||
return Math.abs(cacheStats.limitBytes - optionBytes) < 1000;
|
||||
}
|
||||
|
||||
// Calculate cache usage percentage
|
||||
function getCacheUsagePercent(): number {
|
||||
if (!cacheStats || cacheStats.limitBytes === 0) return 0;
|
||||
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
saving = true;
|
||||
saveMessage = "";
|
||||
await Promise.all([
|
||||
invoke("player_set_audio_settings", { settings }),
|
||||
invoke("player_set_video_settings", { settings: videoSettings }),
|
||||
]);
|
||||
saveMessage = "Settings saved successfully!";
|
||||
setTimeout(() => {
|
||||
saveMessage = "";
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings:", e);
|
||||
saveMessage = "Failed to save settings";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCrossfadeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
settings.crossfadeDuration = parseFloat(target.value);
|
||||
}
|
||||
|
||||
function handleGaplessToggle() {
|
||||
settings.gaplessPlayback = !settings.gaplessPlayback;
|
||||
}
|
||||
|
||||
function handleNormalizeToggle() {
|
||||
settings.normalizeVolume = !settings.normalizeVolume;
|
||||
}
|
||||
|
||||
function handleVolumeLevelChange(level: VolumeLevel) {
|
||||
settings.volumeLevel = level;
|
||||
}
|
||||
|
||||
function handleAutoPlayToggle() {
|
||||
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
||||
}
|
||||
|
||||
function handleCountdownChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
||||
<p class="text-gray-400">Configure playback and audio processing</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Crossfade -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">Crossfade</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Fade between tracks for seamless transitions
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
||||
{settings.crossfadeDuration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
step="0.5"
|
||||
value={settings.crossfadeDuration}
|
||||
oninput={handleCrossfadeChange}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<span>0s (Off)</span>
|
||||
<span>12s (Max)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gapless Playback -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">Gapless Playback</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Eliminate silence between tracks in albums
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleGaplessToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.gaplessPlayback
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'}"
|
||||
aria-label="Toggle gapless playback"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.gaplessPlayback
|
||||
? 'translate-x-7'
|
||||
: 'translate-x-1'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume Normalization -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">Volume Normalization</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Automatically adjust volume levels for consistent playback
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleNormalizeToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.normalizeVolume
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'}"
|
||||
aria-label="Toggle volume normalization"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.normalizeVolume
|
||||
? 'translate-x-7'
|
||||
: 'translate-x-1'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if settings.normalizeVolume}
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<p class="text-sm font-medium text-gray-300 mb-3">Target Volume Level</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onclick={() => handleVolumeLevelChange("loud")}
|
||||
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
||||
'loud'
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
<div class="font-semibold">Loud</div>
|
||||
<div class="text-xs opacity-75">-11 LUFS</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleVolumeLevelChange("normal")}
|
||||
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
||||
'normal'
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
<div class="font-semibold">Normal</div>
|
||||
<div class="text-xs opacity-75">-14 LUFS</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleVolumeLevelChange("quiet")}
|
||||
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
||||
'quiet'
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
<div class="font-semibold">Quiet</div>
|
||||
<div class="text-xs opacity-75">-23 LUFS</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Video Playback Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
|
||||
|
||||
<!-- Auto-play Next Episode -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">Auto-play Next Episode</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Automatically start the next episode when one finishes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleAutoPlayToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {videoSettings.autoPlayNextEpisode
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'}"
|
||||
aria-label="Toggle auto-play next episode"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {videoSettings.autoPlayNextEpisode
|
||||
? 'translate-x-7'
|
||||
: 'translate-x-1'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if videoSettings.autoPlayNextEpisode}
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-300">Countdown Duration</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Time before next episode starts automatically
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
||||
{videoSettings.autoPlayCountdownSeconds}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="30"
|
||||
step="5"
|
||||
value={videoSettings.autoPlayCountdownSeconds}
|
||||
oninput={handleCountdownChange}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<span>5s</span>
|
||||
<span>30s</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Cache Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
|
||||
<!-- Cache Usage -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">Cache Usage</h3>
|
||||
{#if cacheLoading}
|
||||
<span class="text-sm text-gray-400">Loading...</span>
|
||||
{:else if cacheStats}
|
||||
<span class="text-sm text-gray-300">
|
||||
{formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 ? "Unlimited" : formatBytes(cacheStats.limitBytes)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if cacheStats && cacheStats.limitBytes > 0}
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-gray-700 rounded-full h-3 mb-2">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300 {getCacheUsagePercent() > 90 ? 'bg-red-500' : getCacheUsagePercent() > 70 ? 'bg-yellow-500' : 'bg-[var(--color-jellyfin)]'}"
|
||||
style="width: {getCacheUsagePercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm text-gray-400">
|
||||
{#if cacheStats}
|
||||
{cacheStats.itemCount} images cached
|
||||
{:else}
|
||||
Thumbnails and artwork are cached locally for faster loading
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cache Limit -->
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">Storage Limit</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{#each cacheLimitOptions as option}
|
||||
<button
|
||||
onclick={() => handleCacheLimitChange(option.bytes)}
|
||||
class="py-2 px-3 rounded-lg transition-all text-sm {isCurrentLimit(option.bytes)
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
<div class="font-semibold">{option.label}</div>
|
||||
{#if option.default}
|
||||
<div class="text-xs opacity-75">Default</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Cache -->
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">Clear Image Cache</h3>
|
||||
<p class="text-sm text-gray-400">Remove all cached thumbnails and artwork</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleClearCache}
|
||||
disabled={clearingCache || (cacheStats?.itemCount ?? 0) === 0}
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{clearingCache ? "Clearing..." : "Clear Cache"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Downloads</h2>
|
||||
|
||||
<!-- Storage Limit -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold text-white">Storage Limit</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Maximum storage for offline downloads
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
>
|
||||
<div class="font-semibold">5 GB</div>
|
||||
</button>
|
||||
<button
|
||||
class="py-3 px-4 rounded-lg transition-all bg-[var(--color-jellyfin)] text-white"
|
||||
>
|
||||
<div class="font-semibold">10 GB</div>
|
||||
<div class="text-xs opacity-75">Default</div>
|
||||
</button>
|
||||
<button
|
||||
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
>
|
||||
<div class="font-semibold">20 GB</div>
|
||||
</button>
|
||||
<button
|
||||
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
>
|
||||
<div class="font-semibold">Unlimited</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Caching -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">Smart Caching</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Automatically download albums you're listening to
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
|
||||
aria-label="Toggle album affinity (coming soon)"
|
||||
disabled
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Pre-caching -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Download next 5 tracks in queue automatically
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
|
||||
aria-label="Toggle queue pre-caching (coming soon)"
|
||||
disabled
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Only -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">WiFi Only</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Only download when connected to WiFi
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
|
||||
aria-label="Toggle WiFi only downloads (coming soon)"
|
||||
disabled
|
||||
>
|
||||
<span
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm">
|
||||
{#if saveMessage}
|
||||
<span
|
||||
class="text-{saveMessage.includes('success')
|
||||
? 'green'
|
||||
: 'red'}-400"
|
||||
>
|
||||
{saveMessage}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={saving}
|
||||
class="px-6 py-3 bg-[var(--color-jellyfin)] text-white rounded-lg font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||
<div class="flex gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-300">
|
||||
<p class="font-semibold mb-1">About these settings:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
||||
<li>
|
||||
<strong>Crossfade</strong> smoothly blends the end of one track with the
|
||||
beginning of the next
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gapless</strong> removes silence between tracks for continuous
|
||||
album playback
|
||||
</li>
|
||||
<li>
|
||||
<strong>Normalization</strong> uses ReplayGain tags and real-time
|
||||
loudnorm filtering
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user