Duncan Tourolle 1836615dc0
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
feat(library): genre sliders, artist links, and navigation utils
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00

203 lines
6.6 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 { navigateBack } from "$lib/utils/navigation";
import { auth } from "$lib/stores/auth";
import { currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
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);
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={() => navigateBack("/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}