Files
jellytau/src/routes/library/music/+page.svelte
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

220 lines
7.0 KiB
Svelte

<!-- TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { auth } from "$lib/stores/auth";
import { library, currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
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: "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",
},
{
id: "playlists",
name: "Playlists",
icon: "M4 6h16M4 10h16M4 14h10M14 14v6l5-3-5-3z",
description: "Your playlists",
route: "/library/music/playlists",
},
];
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await music.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
// Albums, artists, and playlists all browse to a detail page.
goto(`/library/${item.id}`);
}
const heroItems = $derived($music.heroItems);
const recentlyPlayed = $derived($music.recentlyPlayed);
const newlyAdded = $derived($music.newlyAdded);
const playlists = $derived($music.playlists);
const rediscover = $derived($music.rediscover);
const genreRows = $derived($music.genreRows);
const isLoading = $derived($music.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
recentlyPlayed.length > 0 ||
newlyAdded.length > 0 ||
playlists.length > 0 ||
rediscover.length > 0,
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1>
<button
onclick={() => {
library.setCurrentLibrary(null);
navigateUp("/library");
}}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
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>
</div>
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Recently Listened -->
{#if recentlyPlayed.length > 0}
<Carousel title="Recently Listened" items={recentlyPlayed} onItemClick={handleItemClick} />
{/if}
<!-- Playlists -->
{#if playlists.length > 0}
<Carousel
title="Playlists"
items={playlists}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/playlists")}
/>
{/if}
<!-- Newly Added -->
{#if newlyAdded.length > 0}
<Carousel
title="Newly Added"
items={newlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/albums")}
/>
{/if}
<!-- Rediscover -->
{#if rediscover.length > 0}
<Carousel
title="Haven't listened to in a while"
items={rediscover}
onItemClick={handleItemClick}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/genres")}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">
Nothing here yet. Start playing some music to fill this page.
</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div
class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform"
>
<svg
class="w-5 h-5 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}