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
This commit is contained in:
@@ -26,17 +26,28 @@
|
||||
|
||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||
|
||||
// The strip stretches to fill the height of its scroll container (the page's
|
||||
// <main>, which sits below the sticky header) from the sticky top offset down
|
||||
// to the bottom gap reserved for the nav / mini-player bars. We measure the
|
||||
// container's visible height live so it stays correct regardless of header
|
||||
// size, platform, or window resizes.
|
||||
// The strip stretches from where it sits down to just above the bottom nav /
|
||||
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
||||
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
||||
// top against that floor (clamped to non-negative) and update on scroll/resize
|
||||
// so it never slides under the bars regardless of header or platform.
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let viewportHeight = $state(0);
|
||||
let stripHeight = $state(0);
|
||||
|
||||
function measure() {
|
||||
const scroller = container?.closest("main") ?? document.documentElement;
|
||||
viewportHeight = scroller.clientHeight;
|
||||
if (!container) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
const floor = window.innerHeight - remToPx(bottomGap);
|
||||
stripHeight = Math.max(0, floor - top);
|
||||
}
|
||||
|
||||
function remToPx(len: string): number {
|
||||
const n = parseFloat(len);
|
||||
if (len.trim().endsWith("rem")) {
|
||||
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return n * root;
|
||||
}
|
||||
return n; // assume px otherwise
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -44,13 +55,21 @@
|
||||
const scroller = container?.closest("main");
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (scroller) ro.observe(scroller);
|
||||
scroller?.addEventListener("scroll", measure, { passive: true });
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
scroller?.removeEventListener("scroll", measure);
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
});
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$effect(() => {
|
||||
bottomGap;
|
||||
measure();
|
||||
});
|
||||
|
||||
const letters = $derived([HASH, ...ALPHABET]);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
|
||||
@@ -99,7 +118,7 @@
|
||||
<div
|
||||
bind:this={container}
|
||||
class="flex flex-col items-center justify-between select-none touch-none py-1"
|
||||
style="height: calc({viewportHeight}px - 1.5rem - {bottomGap})"
|
||||
style="height: {stripHeight}px"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="navigation"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { ArtistItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* Renders a comma-separated list of artist names. When the artists carry
|
||||
* ids (artistItems) each name links to that artist's detail page; otherwise
|
||||
* it falls back to plain text from the names-only `artists` array.
|
||||
*
|
||||
* Consolidates the artist-linking markup used on the player and detail
|
||||
* screens so every surface behaves the same way.
|
||||
*/
|
||||
interface Props {
|
||||
artistItems?: ArtistItem[] | null;
|
||||
artists?: string[] | null;
|
||||
/** Tailwind classes for the link colour/state. */
|
||||
linkClass?: string;
|
||||
/** Tailwind classes for the non-linkable fallback text. */
|
||||
textClass?: string;
|
||||
/** Called before navigating (e.g. to close a full-screen player). */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
artistItems = null,
|
||||
artists = null,
|
||||
linkClass = "text-[var(--color-jellyfin)] hover:underline",
|
||||
textClass = "text-gray-400",
|
||||
onNavigate,
|
||||
}: Props = $props();
|
||||
|
||||
const linkable = $derived(
|
||||
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
|
||||
);
|
||||
|
||||
function handleClick(artistId: string, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onNavigate?.();
|
||||
goto(`/library/${artistId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if linkable.length > 0}
|
||||
<span class="inline-flex flex-wrap items-baseline gap-1">
|
||||
{#each linkable as artist, i (artist.id)}
|
||||
<button onclick={(e) => handleClick(artist.id, e)} class={linkClass}>
|
||||
{artist.name}
|
||||
</button>{#if i < linkable.length - 1}<span class={textClass}>,</span>{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{:else if artists && artists.length > 0}
|
||||
<span class={textClass}>{artists.join(", ")}</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
import ArtistLinks from "./ArtistLinks.svelte";
|
||||
|
||||
const goto = vi.fn();
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: (...args: unknown[]) => goto(...args),
|
||||
}));
|
||||
|
||||
describe("ArtistLinks", () => {
|
||||
beforeEach(() => goto.mockClear());
|
||||
|
||||
it("renders a clickable link per artist that navigates to its detail page", async () => {
|
||||
render(ArtistLinks, {
|
||||
artistItems: [
|
||||
{ id: "a1", name: "Radiohead" },
|
||||
{ id: "a2", name: "Thom Yorke" },
|
||||
],
|
||||
});
|
||||
|
||||
const link = screen.getByRole("button", { name: "Radiohead" });
|
||||
await fireEvent.click(link);
|
||||
|
||||
expect(goto).toHaveBeenCalledWith("/library/a1");
|
||||
expect(screen.getByRole("button", { name: "Thom Yorke" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to plain text when only names (no ids) are available", () => {
|
||||
render(ArtistLinks, { artists: ["Unknown Artist"] });
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.getByText("Unknown Artist")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores artistItems with blank ids and uses the names fallback", () => {
|
||||
render(ArtistLinks, {
|
||||
artistItems: [{ id: " ", name: "No Id Artist" }],
|
||||
artists: ["No Id Artist"],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.getByText("No Id Artist")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onNavigate before navigating (e.g. to close a player)", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
render(ArtistLinks, {
|
||||
artistItems: [{ id: "a1", name: "Radiohead" }],
|
||||
onNavigate,
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Radiohead" }));
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledOnce();
|
||||
expect(goto).toHaveBeenCalledWith("/library/a1");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
@@ -125,7 +126,7 @@
|
||||
selectedGenre = null;
|
||||
genreItems = [];
|
||||
} else {
|
||||
goto(config.backPath);
|
||||
navigateBack(config.backPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||
@@ -130,7 +131,7 @@
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto(config.backPath);
|
||||
navigateBack(config.backPath);
|
||||
}
|
||||
|
||||
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
||||
|
||||
@@ -43,6 +43,18 @@
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let isPlaying = $state(false);
|
||||
let currentTime = $state(0);
|
||||
// Guards against onEnded() firing more than once per loaded stream. For
|
||||
// transcoded HLS, both the native `ended` event and the "fatal network error
|
||||
// near end of stream" recovery path can fire for the same playback, which
|
||||
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
|
||||
// sleep-timer episode counter twice). Reset when the stream URL changes.
|
||||
let endedFired = $state(false);
|
||||
|
||||
function notifyEnded() {
|
||||
if (endedFired) return;
|
||||
endedFired = true;
|
||||
onEnded?.();
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
let showSleepTimerModal = $state(false);
|
||||
@@ -188,6 +200,7 @@
|
||||
seekOffset = 0;
|
||||
isMediaReady = false; // Reset to loading state when stream URL changes
|
||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||
endedFired = false; // New stream loaded - allow onEnded to fire again
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,9 +298,7 @@
|
||||
if (isNearEnd) {
|
||||
// Near end of stream - treat as natural end, don't restart
|
||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||
if (onEnded) {
|
||||
onEnded();
|
||||
}
|
||||
notifyEnded();
|
||||
} else if (hlsFatalRecoveryAttempts <= 3) {
|
||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
hls!.startLoad();
|
||||
@@ -762,9 +773,7 @@
|
||||
onReportStop(currentTime, reportMediaId);
|
||||
}
|
||||
// Notify parent that video has ended (for next episode popup)
|
||||
if (onEnded) {
|
||||
onEnded();
|
||||
}
|
||||
notifyEnded();
|
||||
}
|
||||
|
||||
async function togglePlayPause() {
|
||||
|
||||
Reference in New Issue
Block a user