feat(library): focused music/TV/movie landing screens + self-draining download queue
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { movies } from "$lib/stores/movies";
|
||||
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: "all",
|
||||
name: "All Movies",
|
||||
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
|
||||
description: "Browse all movies",
|
||||
route: "/library/movies/all",
|
||||
},
|
||||
{
|
||||
id: "genres",
|
||||
name: "Genres",
|
||||
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
||||
description: "Browse by genre",
|
||||
route: "/library/movies/genres",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
if (!$currentLibrary) {
|
||||
goto("/library");
|
||||
return;
|
||||
}
|
||||
await movies.loadSections($currentLibrary.id);
|
||||
}
|
||||
|
||||
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
||||
|
||||
onMount(async () => {
|
||||
await load();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
checkServerReachability($isServerReachable);
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
if (item.type === "Folder") {
|
||||
goto(`/library/${item.id}`);
|
||||
} else {
|
||||
// Movies play directly.
|
||||
goto(`/player/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const heroItems = $derived($movies.heroItems);
|
||||
const continueWatching = $derived($movies.continueWatching);
|
||||
const recentlyAdded = $derived($movies.recentlyAdded);
|
||||
const genreRows = $derived($movies.genreRows);
|
||||
const isLoading = $derived($movies.isLoading);
|
||||
const hasContent = $derived(
|
||||
heroItems.length > 0 ||
|
||||
continueWatching.length > 0 ||
|
||||
recentlyAdded.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">{$currentLibrary?.name ?? "Movies"}</h1>
|
||||
<button
|
||||
onclick={() => goto("/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}
|
||||
|
||||
<!-- Continue Watching -->
|
||||
{#if continueWatching.length > 0}
|
||||
<Carousel
|
||||
title="Continue Watching"
|
||||
items={continueWatching}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Added -->
|
||||
{#if recentlyAdded.length > 0}
|
||||
<Carousel
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/movies/all")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- One slider per genre -->
|
||||
{#each genreRows as row (row.id)}
|
||||
<Carousel
|
||||
title={row.name}
|
||||
items={row.items}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto(`/library/movies/genres`)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library 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}
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
||||
|
||||
/**
|
||||
* Movie browser (all movies)
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-008 - Search media across libraries
|
||||
* @req: DR-007 - Library browsing screens
|
||||
*/
|
||||
|
||||
const config = {
|
||||
itemType: "Movie" as const,
|
||||
title: "Movies",
|
||||
backPath: "/library/movies",
|
||||
searchPlaceholder: "Search movies...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "A-Z" },
|
||||
{ key: "ProductionYear", label: "Year" },
|
||||
{ key: "DateCreated", label: "Recently Added" },
|
||||
{ key: "CommunityRating", label: "Rating" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
</script>
|
||||
|
||||
<GenericMediaListPage {config} />
|
||||
Reference in New Issue
Block a user