Add a search scope (all/music/shows/movies) resolved from the entry route and adjustable via filter chips, threaded through the library store's search() into includeItemTypes. Results group by type in a user-configurable order, editable from settings. TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
108 lines
3.5 KiB
Svelte
108 lines
3.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/stores";
|
|
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
|
|
import { library } from "$lib/stores/library";
|
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
|
import Search from "$lib/components/Search.svelte";
|
|
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
|
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
|
import AppHeader from "$lib/components/AppHeader.svelte";
|
|
import BottomUi from "$lib/components/BottomUi.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 showSleepTimerModal = $state(false);
|
|
|
|
onMount(() => {
|
|
return () => {
|
|
scrollGuard.cleanup();
|
|
};
|
|
});
|
|
|
|
// Redirect to login if not authenticated
|
|
$effect(() => {
|
|
if (!$isAuthLoading && !$isAuthenticated) {
|
|
goto("/");
|
|
}
|
|
});
|
|
|
|
// The header search outlives navigation, so the route seeds the scope only
|
|
// while no search is active. Once the user has typed (or picked a chip),
|
|
// their scope governs until they clear the query — navigating must not snap
|
|
// a widened search back to the section they happen to be in.
|
|
// TRACES: UR-049 | DR-064
|
|
let searchScope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
|
|
|
|
$effect(() => {
|
|
const pathname = $page.url.pathname;
|
|
if (!searchQuery.trim()) {
|
|
searchScope = resolveSearchScope(pathname);
|
|
}
|
|
});
|
|
|
|
async function handleSearch(query: string) {
|
|
if (query.trim()) {
|
|
await library.search(query, searchScope);
|
|
} else {
|
|
library.clearSearch();
|
|
}
|
|
}
|
|
|
|
async function handleScopeChange(next: SearchScope) {
|
|
searchScope = next;
|
|
if (searchQuery.trim()) {
|
|
await library.search(searchQuery, next);
|
|
}
|
|
}
|
|
</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 (shared across all authenticated chrome; library supplies search) -->
|
|
<AppHeader search={librarySearch} />
|
|
|
|
{#snippet librarySearch()}
|
|
<Search
|
|
bind:value={searchQuery}
|
|
placeholder="Search your library..."
|
|
onSearch={handleSearch}
|
|
/>
|
|
{#if searchQuery.trim()}
|
|
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
|
scroller is physically bounded above it and its last row can never
|
|
render behind the nav — no measurement, no reserved padding. -->
|
|
<main
|
|
class="flex-1 overflow-y-auto p-4 min-h-0"
|
|
style="overscroll-behavior: contain"
|
|
onscroll={scrollGuard.onScroll}
|
|
>
|
|
{@render children()}
|
|
</main>
|
|
|
|
<!-- Bottom UI (mini player + nav), in-flow below the scroller. -->
|
|
<BottomUi />
|
|
|
|
<!-- Sleep Timer Modal -->
|
|
<SleepTimerModal
|
|
isOpen={showSleepTimerModal}
|
|
onClose={() => showSleepTimerModal = false}
|
|
/>
|
|
</div>
|
|
{/if}
|