Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s

This commit is contained in:
2026-07-01 23:49:51 +02:00
parent 342f95cac1
commit 75014ee00f
22 changed files with 880 additions and 149 deletions
+19 -2
View File
@@ -1596,7 +1596,12 @@ export type MediaItem = { id: string; name: string; type: string;
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* Media session type tracking the high-level playback context
*/
@@ -1938,6 +1943,13 @@ export type PlayerStatusEvent =
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control — the frontend pauses it on this
* event.
*/
{ type: "sleep_timer_expired" } |
/**
* Show next episode popup with countdown
*/
@@ -1987,7 +1999,12 @@ export type PlaylistEntry =
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/
+2 -1
View File
@@ -2,6 +2,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
@@ -47,7 +48,7 @@
<!-- Library Button -->
<button
onclick={() => goto('/library')}
onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
+16 -1
View File
@@ -12,7 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
interface Props {
media: MediaItem | null;
@@ -209,6 +209,21 @@
});
// Pause playback when the time-based sleep timer expires. The backend stops
// its own (MPV/ExoPlayer) playback itself, but the HTML5 <video> element
// plays in the webview outside the backend's control, so it must be paused
// here or the sleep timer never actually stops video playback on Linux.
let lastSleepExpirySeen = $sleepTimerExpiredSignal;
$effect(() => {
if ($sleepTimerExpiredSignal !== lastSleepExpirySeen) {
lastSleepExpirySeen = $sleepTimerExpiredSignal;
if (useHtml5Element && videoElement && !videoElement.paused) {
console.log("[VideoPlayer] Sleep timer expired - pausing playback");
videoElement.pause();
}
}
});
// Set up HLS.js for HLS streams
$effect(() => {
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
+7 -1
View File
@@ -13,7 +13,7 @@ import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$
import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer";
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode";
import { autoPlayNext } from "$lib/services/nextEpisodeService";
import { preloadUpcomingTracks } from "$lib/services/preload";
@@ -113,6 +113,12 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
handleSleepTimerChanged(event.mode, event.remaining_seconds);
break;
case "sleep_timer_expired":
// Backend stops its own playback; this signal lets HTML5 video (which
// plays outside the backend on Linux) pause itself too.
sleepTimerExpiredSignal.update((n) => n + 1);
break;
case "show_next_episode_popup":
handleShowNextEpisodePopup(
event.current_episode,
+19 -26
View File
@@ -4,6 +4,7 @@
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the movies in it. */
export interface GenreRow {
@@ -42,30 +43,9 @@ function createMoviesStore() {
const { subscribe, set, update } = writable<MoviesState>(initialState);
/**
* Build the hero rotation. Prefer in-progress movies (most personal), then
* fall back to recently added. De-duplicates by id and prefers items that
* carry backdrop/primary artwork for a good banner.
*/
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
/** Artwork check for hero candidates: needs a backdrop or a primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
async function loadSections(libraryId: string) {
update(s => ({
@@ -77,12 +57,25 @@ function createMoviesStore() {
try {
const repo = auth.getRepository();
const [resume, latest] = await Promise.all([
const [resume, latest, surprise] = await Promise.all([
repo.getResumeMovies(SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["Movie"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
const heroItems = buildHero(resume, latest);
// Mix the hero: in-progress movies first (most personal), then recent
// additions, then random picks from across the library.
const heroItems = buildHeroMix([resume, latest, surprise], hasArt);
update(s => ({
...s,
+20 -29
View File
@@ -6,6 +6,7 @@ import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the albums in it. */
export interface GenreRow {
@@ -52,33 +53,9 @@ function createMusicStore() {
const { subscribe, set, update } = writable<MusicState>(initialState);
/**
* Build the hero rotation from a mix of recently played and rediscover
* albums, interleaved so the banner alternates "fresh in your ears" with
* "remember this?". De-duplicates by id and prefers items that have artwork.
*/
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
const recentPool = recent.filter(hasArt);
const rediscoverPool = rediscover.filter(hasArt);
const result: MediaItem[] = [];
const seen = new Set<string>();
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
for (let i = 0; i < maxLen && result.length < 6; i++) {
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
if (candidate && !seen.has(candidate.id)) {
seen.add(candidate.id);
result.push(candidate);
}
}
}
return result.slice(0, 6);
}
/** Artwork check for hero candidates: needs a primary image or backdrop. */
const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
async function loadSections(libraryId: string) {
update(s => ({
@@ -90,7 +67,7 @@ function createMusicStore() {
try {
const repo = auth.getRepository();
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all([
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
@@ -107,6 +84,17 @@ function createMusicStore() {
limit: SECTION_LIMIT,
}),
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
// HACK: drop the "Podcasts" folder that lives inside the music library.
@@ -114,8 +102,11 @@ function createMusicStore() {
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover);
const surpriseAlbums = excludePodcasts(surprise);
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums);
// Mix the hero: fresh-in-your-ears first, then "remember this?", then
// random albums from across the library.
const heroItems = buildHeroMix([recentlyPlayedAlbums, rediscoverAlbums, surpriseAlbums], hasArt);
update(s => ({
...s,
+8
View File
@@ -58,6 +58,14 @@ function createSleepTimerStore() {
export const sleepTimer = createSleepTimerStore();
/**
* Incremented each time the backend reports the time-based sleep timer
* expired. The backend stops its own (MPV/ExoPlayer) playback itself, but
* HTML5 video on Linux plays in the webview outside the backend's control
* VideoPlayer watches this signal and pauses the <video> element.
*/
export const sleepTimerExpiredSignal = writable(0);
// Derived stores for convenient access
export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode);
+21 -32
View File
@@ -4,6 +4,7 @@
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -45,36 +46,11 @@ function createTvStore() {
const { subscribe, set, update } = writable<TvState>(initialState);
/**
* Build the hero rotation. Prefer in-progress episodes (most personal),
* then fall back to next-up, then recently added. De-duplicates by id and
* prefers items that carry backdrop/primary artwork for a good banner.
*/
function buildHero(
continueWatching: MediaItem[],
nextUp: MediaItem[],
recentlyAdded: MediaItem[]
): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
/** Artwork check for hero candidates: own/parent backdrop or primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
async function loadSections(libraryId: string) {
update(s => ({
@@ -86,17 +62,30 @@ function createTvStore() {
try {
const repo = auth.getRepository();
const [resume, nextUp, latest] = await Promise.all([
const [resume, nextUp, latest, surprise] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["Series"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
const heroItems = buildHero(continueWatching, nextUp, latest);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt);
update(s => ({
...s,
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { buildHeroMix, shuffle } from "./heroMix";
/** Minimal MediaItem for hero tests; `art: false` strips the image tags. */
function item(id: string, art = true): MediaItem {
return {
id,
name: `Item ${id}`,
type: "Movie",
primaryImageTag: art ? `tag-${id}` : undefined,
} as MediaItem;
}
const hasArt = (i: MediaItem) => !!i.primaryImageTag;
describe("shuffle", () => {
it("returns a permutation without mutating the input", () => {
const input = [1, 2, 3, 4, 5];
const copy = [...input];
const result = shuffle(input);
expect(input).toEqual(copy);
expect([...result].sort()).toEqual([...input].sort());
});
});
describe("buildHeroMix", () => {
it("returns empty for empty pools", () => {
expect(buildHeroMix([[], []], hasArt)).toEqual([]);
});
it("caps the rotation at count", () => {
const pool = Array.from({ length: 20 }, (_, i) => item(`a${i}`));
expect(buildHeroMix([pool], hasArt, 6)).toHaveLength(6);
});
it("filters items without artwork", () => {
const result = buildHeroMix([[item("a", false), item("b")]], hasArt);
expect(result.map(i => i.id)).toEqual(["b"]);
});
it("de-duplicates across pools", () => {
const a = item("a");
const result = buildHeroMix([[a], [a, item("b")]], hasArt);
const ids = result.map(i => i.id);
expect(ids).toHaveLength(new Set(ids).size);
expect(ids).toContain("a");
expect(ids).toContain("b");
});
it("leads with an item from the first non-empty pool", () => {
const personal = [item("p1"), item("p2"), item("p3")];
const rest = [item("r1"), item("r2"), item("r3")];
for (let run = 0; run < 20; run++) {
const result = buildHeroMix([personal, rest], hasArt);
expect(["p1", "p2", "p3"]).toContain(result[0].id);
}
});
it("takes at most perPool items per pool before backfilling", () => {
const a = [item("a1"), item("a2"), item("a3"), item("a4")];
const b = [item("b1"), item("b2"), item("b3"), item("b4")];
// count 4 with perPool 2: exactly 2 from each pool, no backfill needed.
for (let run = 0; run < 20; run++) {
const result = buildHeroMix([a, b], hasArt, 4, 2);
const fromA = result.filter(i => i.id.startsWith("a")).length;
const fromB = result.filter(i => i.id.startsWith("b")).length;
expect(fromA).toBe(2);
expect(fromB).toBe(2);
}
});
it("backfills from leftovers when samples fall short of count", () => {
const a = [item("a1"), item("a2"), item("a3"), item("a4"), item("a5"), item("a6")];
const result = buildHeroMix([a], hasArt, 6, 2);
expect(result).toHaveLength(6);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Hero banner mix builder, shared by the movies/TV/music landing stores.
//
// The hero used to show the first N items of fixed pools (continue watching,
// recently played, latest), which made it identical on every visit. Instead we
// sample a couple of items at random from each pool — pools are ordered most
// personal first — and shuffle the rotation, so the banner is a fresh mix each
// time while still leading with something personal.
// TRACES: UR-034 | DR-038
import type { MediaItem } from "$lib/api/types";
/** Fisher-Yates shuffle; returns a new array, input is untouched. */
export function shuffle<T>(items: T[]): T[] {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/**
* Build a varied hero rotation from ordered pools (most personal first).
*
* Takes up to `perPool` random items from each pool, de-duplicated by id and
* filtered to items with usable artwork. The first pick from the first
* non-empty pool leads the rotation (so a resumable/recent item greets the
* user), the rest are shuffled. If that yields fewer than `count` items, the
* remainder is backfilled from whatever is left across all pools.
*/
export function buildHeroMix(
pools: MediaItem[][],
hasArt: (item: MediaItem) => boolean,
count = 6,
perPool = 2
): MediaItem[] {
const seen = new Set<string>();
const usable = pools.map(pool =>
pool.filter(item => {
if (!hasArt(item) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
})
);
const picked = new Set<string>();
const picks: MediaItem[] = [];
for (const pool of usable) {
for (const item of shuffle(pool).slice(0, perPool)) {
picked.add(item.id);
picks.push(item);
}
}
if (picks.length === 0) return [];
const [leader, ...rest] = picks;
const leftovers = shuffle(usable.flat().filter(item => !picked.has(item.id)));
return [leader, ...shuffle(rest), ...leftovers].slice(0, count);
}