214 lines
8.4 KiB
Svelte
214 lines
8.4 KiB
Svelte
<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 "../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";
|
|
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal, bottomUiHeight } from "$lib/stores/appState";
|
|
// Shuffle/repeat/next/previous come from the event-driven queue store, the
|
|
// single source of truth (updated instantly on queue_changed).
|
|
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
|
|
|
|
let { children } = $props();
|
|
|
|
// The fixed bottom UI (mini player stacked over the bottom nav) is measured in
|
|
// real time and its height published to `bottomUiHeight`, so pages can reserve
|
|
// exactly that much space instead of guessing fixed rem values.
|
|
let bottomUiEl = $state<HTMLElement | null>(null);
|
|
|
|
// Route-level visibility for the fixed bottom UI (the mini player itself also
|
|
// self-gates on playback state; when it renders nothing the in-flow slot
|
|
// collapses to 0 and the ResizeObserver shrinks the reserved padding).
|
|
const pathname = $derived($page.url.pathname);
|
|
const showBottomNav = $derived(
|
|
$isAuthenticated && !pathname.startsWith('/player/') && !pathname.startsWith('/login')
|
|
);
|
|
const showGlobalMiniPlayer = $derived(
|
|
!pathname.startsWith('/player/') &&
|
|
!pathname.startsWith('/login') &&
|
|
!pathname.startsWith('/settings') &&
|
|
($isAndroid || !pathname.startsWith('/library'))
|
|
);
|
|
|
|
$effect(() => {
|
|
const el = bottomUiEl;
|
|
if (!el) {
|
|
bottomUiHeight.set(0);
|
|
return;
|
|
}
|
|
const ro = new ResizeObserver((entries) => {
|
|
bottomUiHeight.set(entries[0]?.contentRect.height ?? el.offsetHeight);
|
|
});
|
|
ro.observe(el);
|
|
bottomUiHeight.set(el.offsetHeight);
|
|
return () => {
|
|
ro.disconnect();
|
|
bottomUiHeight.set(0);
|
|
};
|
|
});
|
|
|
|
onMount(async () => {
|
|
// Detect platform first (synchronously, before any await) so the global
|
|
// mini player's Android visibility gate is correct from the first render.
|
|
// Android needs the global mini player because the library layout hides its
|
|
// own; if this ran after `await auth.initialize()` the store stayed false
|
|
// long enough that the mini player never appeared on library routes.
|
|
try {
|
|
const platformName = platform();
|
|
isAndroid.set(platformName === "android");
|
|
} catch (err) {
|
|
console.error("Platform detection failed:", err);
|
|
}
|
|
|
|
// Initialize auth state (restore session from secure storage)
|
|
await auth.initialize();
|
|
isInitialized.set(true);
|
|
|
|
// 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();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
cleanupPlayerEvents();
|
|
cleanupDownloadEvents();
|
|
connectivity.stopMonitoring();
|
|
syncService.stop();
|
|
auth.cleanupEventListeners();
|
|
});
|
|
|
|
// 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)
|
|
auth.getCurrentSession().then((session) => {
|
|
if (session?.serverUrl) {
|
|
connectivity.forceCheck().catch((error) => {
|
|
// If check fails, monitoring might not be started yet, so start it
|
|
console.debug("[Layout] Queue status check failed, starting monitoring:", error);
|
|
connectivity.startMonitoring(session.serverUrl, {
|
|
onServerReconnected: () => {
|
|
// Retry session verification when server becomes reachable
|
|
auth.retryVerification();
|
|
},
|
|
}).catch((monitorError) => {
|
|
console.error("[Layout] Failed to start connectivity monitoring:", monitorError);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update pending sync count periodically
|
|
$effect(() => {
|
|
if ($isAuthenticated) {
|
|
const updateCount = async () => {
|
|
const count = await syncService.getPendingCount();
|
|
pendingSyncCount.set(count);
|
|
};
|
|
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 />
|
|
|
|
<!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal
|
|
flow inside one measured wrapper. The wrapper's height is observed and
|
|
published to `bottomUiHeight` so pages reserve exactly this much space.
|
|
Mini player is first (visually on top, above the nav). -->
|
|
{#if showBottomNav || showGlobalMiniPlayer}
|
|
<div bind:this={bottomUiEl} class="fixed bottom-0 left-0 right-0 z-40 flex flex-col">
|
|
{#if showGlobalMiniPlayer}
|
|
<MiniPlayer
|
|
media={$currentMedia}
|
|
isPlaying={$isPlaying}
|
|
position={$playbackPosition}
|
|
duration={$playbackDuration}
|
|
shuffle={$shuffle}
|
|
repeat={$repeat}
|
|
hasNext={$hasNext}
|
|
hasPrevious={$hasPrevious}
|
|
className="flex-shrink-0"
|
|
onExpand={() => {
|
|
// Navigate to player page when mini player is expanded
|
|
if ($currentMedia) {
|
|
goto(`/player/${$currentMedia.id}`);
|
|
}
|
|
}}
|
|
onSleepTimerClick={() => showSleepTimerModal.set(true)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if showBottomNav}
|
|
<BottomNav className="flex-shrink-0" />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Sleep Timer Modal -->
|
|
<SleepTimerModal
|
|
isOpen={$showSleepTimerModal}
|
|
onClose={() => showSleepTimerModal.set(false)}
|
|
/>
|
|
{/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>
|