Replace the remaining ~155 untyped invoke() calls across stores, services, components, and routes with the generated commands.* wrappers from $lib/api/bindings, so every IPC call is compile-time-checked against the command signatures. - Register repository_get_subtitle_url and repository_get_video_download_url in specta_builder() and the invoke_handler; regenerate bindings.ts. - Source duplicated wire types (AutoplaySettings, CacheConfig, Session, ConnectivityStatus, audio/video settings, etc.) from bindings. - Fix two bugs surfaced by the typed wrappers: - VideoDownloadButton passed an un-awaited Promise as the stream URL. - setAutoplaySettings omitted the required userId argument. - Update unit tests asserting the old invoke(name, args) shape. - Remove the five param-naming guard tests; the compiler and codegen now enforce what they checked. svelte-check: 0 errors. vitest: green. cargo test --lib: green.
294 lines
12 KiB
Svelte
294 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/stores";
|
|
import { commands } from "$lib/api/bindings";
|
|
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, shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
|
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";
|
|
|
|
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
|
const scrollGuard = useScrollGuard(300);
|
|
setContext("scrollGuard", scrollGuard);
|
|
|
|
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(() => {
|
|
// Detect platform
|
|
try {
|
|
const platformName = 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);
|
|
scrollGuard.cleanup();
|
|
};
|
|
});
|
|
|
|
// Redirect to login if not authenticated
|
|
$effect(() => {
|
|
if (!$isAuthLoading && !$isAuthenticated) {
|
|
goto("/");
|
|
}
|
|
});
|
|
|
|
async function updateQueueStatus() {
|
|
try {
|
|
const queue = await commands.playerGetQueue();
|
|
|
|
// 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="ml-auto 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 bottom nav bar and mini player) -->
|
|
<main
|
|
class="flex-1 overflow-y-auto p-4"
|
|
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
|
|
onscroll={scrollGuard.onScroll}
|
|
>
|
|
{@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}
|