feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s

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:
2026-06-24 20:44:17 +02:00
parent dcf08f30bc
commit 17a35573a0
33 changed files with 2045 additions and 188 deletions
@@ -4,6 +4,8 @@
import { goto } from "$app/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -13,6 +15,8 @@
import type { MediaItem, Library, ItemType } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
import { excludePodcasts } from "$lib/utils/podcastFilter";
/**
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
@@ -41,6 +45,7 @@
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let sortBy = $state<string>("");
@@ -74,12 +79,13 @@
const repo = auth.getRepository();
// Use backend search if search query is provided, otherwise use getItems with sort
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
if (debouncedSearchQuery.trim()) {
const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType],
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
} else {
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType],
@@ -88,7 +94,7 @@
recursive: true,
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
}
} catch (e) {
console.error(`Failed to load ${config.itemType}:`, e);
@@ -142,6 +148,54 @@
goto(`/library/${track.id}`);
}
}
// ===== A-Z jump bar =====
// Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty.
function letterFor(name: string): string {
const first = (name ?? "").trim().charAt(0).toUpperCase();
return first >= "A" && first <= "Z" ? first : "#";
}
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived(
isAlphaSorted &&
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
);
const availableLetters = $derived.by(() => {
const set = new Set<string>();
if (showAlphaBar) {
for (const item of items) set.add(letterFor(item.name));
}
return set;
});
// First item index for each letter, honouring current ascending/descending order.
const firstIndexForLetter = $derived.by(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
const letter = letterFor(item.name);
if (!map.has(letter)) map.set(letter, index);
});
return map;
});
function jumpToLetter(letter: string) {
const index = firstIndexForLetter.get(letter);
if (index === undefined || !gridWrapper) return;
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived(
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
</script>
<div class="space-y-6">
@@ -192,10 +246,19 @@
<p>No {config.title.toLowerCase()} found</p>
</div>
{:else}
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
<div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
</div>
{/if}
</div>
{/if}
</div>