Files
jellytau/src/routes/+page.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

314 lines
9.9 KiB
Svelte

<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 { library, libraries } from "$lib/stores/library";
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 MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
import { assumedLibraryRatio } from "$lib/components/library/libraryMosaic";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import type { MediaItem, Library } from "$lib/api/types";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("HomePage");
// Home scrolls in its own box rather than the shell's, and is destroyed on
// every navigation away — so its offsets live in the module-level memory,
// letting Back return the viewer to their row instead of the top. (DR-156)
let homeScroller = $state<HTMLElement>();
useScrollRestore(() => homeScroller, "home");
// 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) {
log.error("Platform detection failed:", err);
}
if ($isAuthenticated) {
await home.loadHomeSections();
if ($libraries.length === 0) {
await library.loadLibraries();
}
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;
});
// Tap → detail page. Non-playable containers already routed to /library; now
// movies and episodes go to their detail page too instead of playing straight
// away. Channel leaves (no detail page) still go direct to the player.
// TRACES: UR-058 | DR-087
function handleItemClick(item: MediaItem) {
switch (item.kind) {
case "channelItem":
case "liveChannel":
goto(`/player/${item.id}`);
break;
case "episode":
// An episode is never browsed as a bare Episode page — it opens in its
// series' Episode Focus View so the series context loads (ux-flows §5B.1).
if (item.seriesId) {
goto(`/library/${item.seriesId}?episode=${item.id}`);
} else {
goto(`/library/${item.id}`);
}
break;
default:
goto(`/library/${item.id}`);
break;
}
}
// Long press → play immediately, confirming first so an accidental hold on a
// half-watched item doesn't blow away the user's spot without warning.
// TRACES: UR-058 | DR-087
function handleItemLongPress(item: MediaItem) {
switch (item.kind) {
case "movie":
case "episode":
case "channelItem":
case "liveChannel":
if (confirm(`Play "${item.name}" now?`)) {
goto(`/player/${item.id}`);
}
break;
default:
// Containers (series/season/album/…) have no single "play now" target.
goto(`/library/${item.id}`);
break;
}
}
// Playlist libraries live inside the Music landing page, not as top-level shortcuts.
const shortcutLibraries = $derived(
$libraries.filter((lib) => lib.collectionType !== "playlists"),
);
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
// artwork. It used to force 16:9 on everything so square music covers lined up
// with wide backdrops — which lined them up by cropping the covers.
// TRACES: UR-075 | DR-174
const LIBRARY_STRIP_HEIGHT = 132;
const libraryTiles = $derived(
shortcutLibraries.map((lib) => ({
key: lib.id,
ratio: assumedLibraryRatio(lib),
library: lib,
})),
);
function handleLibraryClick(lib: Library) {
// Mirror /library routing: dedicated landing pages need currentLibrary set.
library.setCurrentLibrary(lib);
switch (lib.collectionType) {
case "music":
goto("/library/music");
break;
case "tvshows":
goto("/library/tv");
break;
case "movies":
goto("/library/movies");
break;
default:
library.clearGenres();
goto("/library");
break;
}
}
const heroItems = $derived($home.heroItems);
const resumeItems = $derived(
$home.resumeItems.filter((i) => i.kind === "movie" || i.kind === "episode"),
);
const nextUpItems = $derived($home.nextUpItems);
const latestItems = $derived($home.latestItems);
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
// Favourite rows. Each is hidden when empty, so a fresh install shows none.
// TRACES: UR-067 | DR-118
const favoriteMovies = $derived($home.favoriteMovies);
const favoriteShows = $derived($home.favoriteShows);
const favoriteMusic = $derived($home.favoriteMusic);
const resumeMovies = $derived($home.resumeMovies);
const isLoading = $derived($home.isLoading);
</script>
{#if isLoading}
<div class="h-full 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
bind:this={homeScroller}
class="h-full 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}
<!-- Your Libraries: quick jump into each dedicated landing page -->
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="px-4">
<MosaicGrid
items={libraryTiles}
layout="strip"
targetHeight={LIBRARY_STRIP_HEIGHT}
gap={12}
>
{#snippet tile(entry)}
<MosaicTile
label={entry.library.name}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
{/snippet}
</MosaicGrid>
</div>
</div>
{/if}
<!-- Next Movie -->
{#if resumeMovies.length > 0}
<Carousel
title="Next Movie"
items={resumeMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Next Episode -->
{#if nextUpItems.length > 0}
<Carousel
title="Next Episode"
items={nextUpItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Listened -->
{#if recentlyPlayedAudio.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayedAudio}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Continue Watching -->
{#if resumeItems.length > 0}
<Carousel
title="Continue Watching"
items={resumeItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Added -->
{#if latestItems.length > 0}
<Carousel
title="Recently Added"
items={latestItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Favourites. Hidden entirely when a category has nothing in it —
empty rows on a fresh install read as broken. ux-flows §5C.2.
TRACES: UR-067 | DR-118 -->
{#if favoriteMovies.length > 0}
<Carousel
title="Favourite Movies"
items={favoriteMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=movies")}
/>
{/if}
{#if favoriteShows.length > 0}
<Carousel
title="Favourite Shows"
items={favoriteShows}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=tv")}
/>
{/if}
{#if favoriteMusic.length > 0}
<Carousel
title="Favourite Music"
items={favoriteMusic}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=music")}
/>
{/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}