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.
220 lines
7.0 KiB
Svelte
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}
|