Compare commits

..
11 Commits
Author SHA1 Message Date
dtourolle 5759a97289 chore: ignore Arch packaging build artifacts
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m49s
Build & Release / Build Linux (push) Successful in 17m46s
Build & Release / Build Windows (push) Successful in 13m20s
Build & Release / Build Android (push) Successful in 29m14s
Build & Release / Create Release (push) Successful in 15s
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
2026-07-25 15:53:10 +02:00
dtourolle b9f026e215 chore(release): bump to 0.1.2
Adds CHANGELOG.md, which the release-notes template in docs/release-checklist.md
already linked to but which had never been created.
2026-07-25 15:21:51 +02:00
dtourolle b7a7037194 docs: add UR-060 search relevance requirement; regenerate matrix
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
2026-07-25 15:13:52 +02:00
dtourolle 124da29fc7 fix(search): route the library header search to /search
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.

Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
2026-07-25 15:13:45 +02:00
dtourolle 5927299c0f feat(search): rank results by match quality and split TV/People groups
Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".

Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.

On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
2026-07-25 15:13:32 +02:00
dtourolle 7650efcb7f fix(player): scale video to fill the player viewport
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
2026-07-25 15:12:53 +02:00
dtourolle 4b9350c949 chore(release): bump to 0.1.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m25s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 3m52s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m52s
Build & Release / Build Linux (push) Successful in 17m32s
Build & Release / Build Windows (push) Successful in 13m14s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 13s
2026-07-25 09:25:52 +02:00
dtourolle d01c1216b8 docs: red-green rule for bug fixes; regenerate traceability matrix
CLAUDE.md now states the failing-test-first rule explicitly: write a test
that reproduces the bug and watch it fail before applying the fix, and
extract buried logic into a plain .ts module so it can be unit-tested. A
test written against already-fixed code can pass for the wrong reason.
2026-07-25 09:21:22 +02:00
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00
dtourolle ee584aced2 fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.

- Carry episode identity (item_type, series_id) through the
  background-audio handoff so the backend queue item still knows it's an
  episode; is_episode_item now trusts item_type over the media_type
  heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
  /player/<id>, which is unavailable while the WebView is suspended.
  advance_to_next_episode_audio_only drives it entirely in the backend:
  fetch the next episode, build its audio-only stream URL, and load it
  into the native audio player, preserving episode identity so the
  following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
  backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
  (online delegates to the existing builder, offline errors) so the
  controller can reach it without a frontend round-trip.

TRACES: UR-040, UR-023 | DR-052 | JA-032
2026-07-25 09:21:08 +02:00
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00
41 changed files with 2434 additions and 696 deletions
+6
View File
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
/docs/README.md
/docs/api-redirect.md
/docs-site/book/
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
/.cargo-arch/
/packaging/arch/pkg/
/packaging/arch/src/
/packaging/arch/*.pkg.tar.zst
+47
View File
@@ -0,0 +1,47 @@
# Changelog
All notable changes to JellyTau are documented here.
Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
## v0.1.2
### ✨ Features
- **Search results are ordered by how well they match.** A name that *starts*
with the query now outranks one matching mid-word — typing "parks" finds
"Parks and Recreation" before "Sparks of Love" — and at equal match quality a
container outranks its contents, so a series lands above its own episodes.
Ranking is applied to the instant cached results and to the merged
cache+server list alike, so the list no longer reshuffles when server results
arrive. (UR-060, DR-090)
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
group splits into Shows and Episodes so a show never competes with its own
episodes for a slot, and a new People group means searching an actor's name
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
Albums → Artists → People; a group order saved before the split keeps the
position it was dragged to. (UR-060, DR-091)
### 🐛 Bug Fixes
- **The library header search bar works on every library page.** It previously
searched in place and depended on `/library` rendering results inline, so on
any other `/library/**` route the results were fetched and never shown.
`/search` is now the single surface that renders results, and the header bar
hands its query and scope over via the URL. (UR-049, DR-063)
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
oversized media, so a 480p source on a 1080p display played as a small picture
in the middle of a black frame. The picture now fits whichever axis constrains
it, in both directions, preserving aspect ratio. (UR-005)
### 📋 Requirements
**Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+
## v0.1.1 and earlier
Released before this file existed — see the git history and the release notes on
each tag.
+17
View File
@@ -266,6 +266,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
## Testing
### 🔴 Bug fixes: failing test FIRST, then the fix
When fixing a bug, **write a test that reproduces it and watch it fail before
touching the fix.** Red → green, in that order:
1. Write a test that exercises the broken behavior and **run it — it must fail**,
proving the test actually catches the bug (a test that passes before the fix
proves nothing).
2. Apply the fix.
3. Re-run — the test now passes, and so does the rest of the suite.
Never fix first and backfill the test afterward: a test written against
already-fixed code can pass for the wrong reason and silently fails to guard the
regression. If the logic is buried in a component, extract the pure part into a
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
pattern as `TrackList.logic.test.ts`.
```bash
# Rust
cd src-tauri && cargo test
+8 -1
View File
@@ -69,6 +69,8 @@ For a narrative overview of the system design, see
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
---
@@ -220,7 +222,7 @@ Internal architecture, components, and application logic.
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
@@ -239,6 +241,10 @@ Internal architecture, components, and application logic.
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
---
@@ -306,6 +312,7 @@ Internal architecture, components, and application logic.
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
---
+639 -495
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.1.2",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.1.0"
version = "0.1.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
+13 -2
View File
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
/// background-audio handoff so an episode played as audio-only is still
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
}
/// Queue context for remote transfer - what type of queue is this?
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
item_type: None,
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
+13 -2
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
@@ -409,7 +410,7 @@ pub async fn repository_search(
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
let mut cache_result = repo
.search_cache_only(&query, options.clone())
.await
.unwrap_or_else(|e| {
@@ -420,6 +421,12 @@ pub async fn repository_search(
}
});
// Neither backend orders by *where* the query matched, so a mid-word hit
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
// Both phases are ranked with the same rules so the list does not reshuffle
// when the server results land.
rank_search_results(&mut cache_result.items, &query);
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
@@ -428,7 +435,11 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let mut merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
// Rank the union, not each half: a server-only prefix match must
// be able to outrank a cached mid-word one.
rank_search_results(&mut merged.items, &query);
let event = SearchUpdateEvent {
request_id,
result: merged,
+2
View File
@@ -5,6 +5,8 @@
pub mod from_jellyfin;
pub mod media;
pub mod search_rank;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
pub use search_rank::rank_search_results;
+313
View File
@@ -0,0 +1,313 @@
//! Relevance ranking for search results.
//!
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
//! in an order that ignores *where* in the name the query matched: a server
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
//! the query "parks". Neither backend is going to change, so the app imposes its
//! own ordering on the union.
//!
//! Ranking is domain logic, not presentation: it encodes what a "better match"
//! means and which media kinds outrank which. The frontend only renders the
//! order it is given.
//!
//! Two rules, in priority order:
//!
//! 1. **Match position** — a prefix match beats a word-start match, which beats
//! a mid-word substring match. This is what makes "parks" find
//! "Parks and Recreation" before "Sparks of Love".
//! 2. **Kind** — containers before their contents at equal match quality, so a
//! series outranks its own episodes.
//!
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
//! `rank`) still breaks ties it was never overruled on.
use crate::domain::MediaKind;
use crate::repository::types::MediaItem;
/// How well a query matched an item's name — better matches sort first.
///
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
/// comparison for free, so adding a tier in the right position is all it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchQuality {
/// The name starts with the query — "parks" in "Parks and Recreation".
Prefix,
/// Some later *word* starts with the query — "recreation" in "Parks and
/// Recreation". Still a deliberate hit: users type whole words.
WordStart,
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
/// that still counts as a match.
Substring,
/// No match on the name at all. The backend returned it for some other
/// reason (overview, artist, album), so it is kept but sorted last.
None,
}
/// Rank of a media kind when match quality ties — lower sorts first.
///
/// Containers outrank the items they contain: searching a show's name should
/// surface the show, not an arbitrary episode of it. Within a tier the order is
/// arbitrary but stable, and equal ranks fall through to input order.
fn kind_rank(kind: MediaKind) -> u8 {
match kind {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
}
// Peripheral matches.
MediaKind::Person | MediaKind::Other => 3,
}
}
/// Classify how `query` matches `name`, case-insensitively.
///
/// Both sides are trimmed and lowercased; an empty query matches everything
/// equally (`Prefix`), which leaves the input order untouched.
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
let query = query.trim().to_lowercase();
if query.is_empty() {
return MatchQuality::Prefix;
}
let name = name.trim().to_lowercase();
let Some(index) = name.find(&query) else {
return MatchQuality::None;
};
if index == 0 {
return MatchQuality::Prefix;
}
// A word start is any match preceded by a non-alphanumeric character, so
// "the-office" and "The Office" behave the same. Indexing back one char is
// safe on the byte index `find` returned only via `char_indices`, since a
// multi-byte char would panic on a raw slice.
let preceded_by_boundary = name[..index]
.chars()
.next_back()
.is_some_and(|c| !c.is_alphanumeric());
if preceded_by_boundary {
MatchQuality::WordStart
} else {
MatchQuality::Substring
}
}
/// Sort search results by relevance to `query`, in place.
///
/// Stable, so items the rules rank equally keep the order the backend supplied
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
///
/// TRACES: UR-060 | DR-090
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
// An empty query carries no relevance signal, so there is nothing to rank
// by — reordering on kind alone would shuffle the backend's own ordering
// for no reason.
if query.trim().is_empty() {
return;
}
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
}
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, kind: MediaKind) -> MediaItem {
let mut item = MediaItem::default();
item.id = format!("id-{}-{:?}", name, kind);
item.name = name.to_string();
item.kind = kind;
item
}
fn names(items: &[MediaItem]) -> Vec<&str> {
items.iter().map(|i| i.name.as_str()).collect()
}
/// UT-085: a prefix match outranks a mid-word substring match.
#[test]
fn prefix_match_beats_midword_substring() {
assert_eq!(
match_quality("Parks and Recreation", "parks"),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Sparks of Love", "parks"),
MatchQuality::Substring
);
assert!(MatchQuality::Prefix < MatchQuality::Substring);
}
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
#[test]
fn ranks_prefix_match_before_substring_match() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Sparks of Love"]
);
}
/// A match at a later word start beats a mid-word one but loses to a prefix.
#[test]
fn word_start_ranks_between_prefix_and_substring() {
assert_eq!(
match_quality("The Office", "office"),
MatchQuality::WordStart
);
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
let mut items = vec![
item("Bofficer", MediaKind::Series),
item("The Office", MediaKind::Series),
item("Office Space", MediaKind::Movie),
];
rank_search_results(&mut items, "office");
assert_eq!(
names(&items),
vec!["Office Space", "The Office", "Bofficer"]
);
}
/// UT-086: at equal match quality a series outranks an episode.
#[test]
fn series_ranks_before_episode_at_equal_match_quality() {
let mut items = vec![
item("Parks and Recreation S01E01", MediaKind::Episode),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
);
}
/// Albums outrank their tracks for the same reason series outrank episodes.
#[test]
fn album_ranks_before_track_at_equal_match_quality() {
let mut items = vec![
item("Rumours", MediaKind::Track),
item("Rumours", MediaKind::Album),
];
rank_search_results(&mut items, "rumours");
assert_eq!(items[0].kind, MediaKind::Album);
}
/// Match quality dominates kind: a better-matching episode beats a
/// worse-matching series, so kind never drags an irrelevant show to the top.
#[test]
fn match_quality_outranks_kind() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks Cleanup", MediaKind::Episode),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
}
/// Items the backend returned for a non-name reason (overview, artist) are
/// kept, but sort below everything that actually matched the name.
#[test]
fn non_matching_names_sort_last_without_being_dropped() {
let mut items = vec![
item("Unrelated Documentary", MediaKind::Movie),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Unrelated Documentary"]
);
}
/// Ranking is stable: equally-ranked items keep the backend's order, so the
/// FTS/server relevance signal still breaks ties.
#[test]
fn equal_rank_preserves_input_order() {
let mut items = vec![
item("Parks A", MediaKind::Series),
item("Parks B", MediaKind::Series),
item("Parks C", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
}
/// Case and surrounding whitespace never change the tier.
#[test]
fn matching_is_case_and_whitespace_insensitive() {
assert_eq!(
match_quality("PARKS AND RECREATION", " parks "),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Parks and Recreation", "PARKS"),
MatchQuality::Prefix
);
}
/// An empty query leaves the order alone rather than reshuffling on kind.
#[test]
fn empty_query_preserves_input_order() {
let mut items = vec![
item("Zebra", MediaKind::Episode),
item("Apple", MediaKind::Series),
];
rank_search_results(&mut items, "");
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
}
/// A multi-byte name must not panic when the match is mid-string — the
/// boundary check walks chars rather than slicing raw bytes.
#[test]
fn handles_multibyte_names_without_panicking() {
assert_eq!(
match_quality("Pokémon Journeys", "journeys"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Café Parks", "parks"),
MatchQuality::WordStart
);
}
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
#[test]
fn punctuation_counts_as_a_word_boundary() {
assert_eq!(
match_quality("The-Office", "office"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Show: Parks", "parks"),
MatchQuality::WordStart
);
}
}
+33 -5
View File
@@ -858,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
});
}
// Start countdown if auto_advance enabled
if auto_advance {
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
// Background audio-only episode: the frontend that normally
// performs the advance (goto /player/<id>) is suspended, so
// the backend must load the next episode's audio-only stream
// itself — otherwise playback just stops at the boundary.
let is_bg_audio_episode =
controller.lock().await.current_is_audio_episode();
if is_bg_audio_episode {
log::info!(
"[Autoplay] Background audio episode — advancing to {} in backend",
next_episode.id
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
log::error!(
"[Autoplay] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
ctrl.emit_queue_changed();
}
} else {
// Foreground: frontend drives the advance off the countdown.
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
}
}
Err(e) => {
+200 -10
View File
@@ -658,6 +658,24 @@ impl PlayerController {
self.queue.clone()
}
/// True when the current item is a TV episode being played in audio-only
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
/// `MediaType::Audio`. Used to decide whether the backend must drive the
/// next-episode advance itself (the frontend is suspended in the background).
///
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn current_is_audio_episode(&self) -> bool {
self.queue
.lock_safe()
.current()
.map(|item| {
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
})
.unwrap_or(false)
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
@@ -963,9 +981,11 @@ impl PlayerController {
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
// Only count TV episodes (not audio tracks or movies). Note an
// episode played in background-audio mode is MediaType::Audio, so
// rely on is_episode_item (which checks item_type) rather than the
// media_type alone.
let is_episode = self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -981,10 +1001,12 @@ impl PlayerController {
}
}
// For video episodes, fetch next episode and show popup
// For episodes, fetch next episode and show popup.
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
// It's here for the Android ExoPlayer path where episode items sit in the
// backend queue — including background-audio mode, where the episode is a
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
if self.is_episode_item(&current).await {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
@@ -1042,6 +1064,77 @@ impl PlayerController {
}
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
/// which is unavailable when the app is backgrounded and the WebView is
/// suspended. This drives the advance entirely in the backend: build the next
/// episode's *audio-only* stream URL and load it into the native audio player,
/// so playback continues without any frontend involvement (UR-040).
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
/// TRACES: UR-040, UR-023 | DR-052
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String> {
let repo = self
.repository
.lock_safe()
.clone()
.ok_or_else(|| "No repository for background episode advance".to_string())?;
// Details for session metadata (title/series/artwork) and the stream URL.
let next = repo
.get_item(next_episode_id)
.await
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
// Audio-only transcode from the start of the episode (no resume offset —
// a freshly-started next episode always plays from the beginning).
let stream_url = repo
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
.await
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem {
id: next.id.clone(),
title: next.name.clone(),
name: Some(next.name.clone()),
artist: next.series_name.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: next.primary_image_tag.clone(),
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
// Preserve episode identity so the NEXT end-of-track also advances.
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url,
jellyfin_item_id: next.id.clone(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: next.series_id.clone(),
server_id: Some(next.server_id.clone()),
};
self.play_item(media_item).map_err(|e| e.to_string())
}
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
@@ -1135,11 +1228,19 @@ impl PlayerController {
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query)
/// Check if a media item is an episode (has Jellyfin ID to query).
///
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
/// to the audio path for background playback (UR-040) is still recognised as
/// an episode — otherwise autoplay would fall through to the queue-based
/// audio path, find nothing next, and stop at the episode boundary. When the
/// type is unknown we fall back to the historical heuristic (video == episode).
async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes
// In production, we'd check item metadata or query Jellyfin
item.media_type == MediaType::Video
match item.item_type.as_deref() {
Some("Episode") => true,
Some(_) => item.media_type == MediaType::Video,
None => item.media_type == MediaType::Video,
}
}
/// Fetch next episode for a series by looking up the season's episodes
@@ -2311,6 +2412,15 @@ mod tests {
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, repo_types::RepoError> {
Ok(format!("http://example.com/{}-audio.mp3", item_id))
}
async fn get_live_tv_channels(
&self,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
@@ -2503,6 +2613,86 @@ mod tests {
}
}
/// Background audio-only mode (UR-040): a video episode is handed off to the
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
/// playing while the app is backgrounded. When that audio track ends, autoplay
/// must STILL recognise it as an episode and offer the next one — otherwise
/// playback just pauses at the episode boundary (the reported bug). The item
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
#[tokio::test]
async fn test_playback_ended_background_audio_episode_advances() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem {
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end.
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep3");
}
other => panic!(
"background-audio episode end must advance to the next episode, got {:?}",
other
),
}
}
/// The backend-driven advance (used when backgrounded) must load the next
/// episode as an AUDIO item carrying its episode identity, so the *following*
/// end-of-track also advances rather than stopping.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should be loaded");
assert_eq!(current.id, "ep2");
assert_eq!(current.media_type, MediaType::Audio);
assert_eq!(current.item_type.as_deref(), Some("Episode"));
assert_eq!(current.series_id.as_deref(), Some("series1"));
// Uses the audio-only URL, not a video stream.
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("audio"),
"expected audio-only URL, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
// The controller now considers itself mid background-audio episode, so the
// next end-of-track will advance again rather than stop.
assert!(controller.current_is_audio_episode());
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
+38
View File
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode of a video requires the server - delegate to online.
self.online
.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
@@ -1028,6 +1046,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
@@ -1276,6 +1304,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
+16
View File
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// Used when autoplay advances to the next episode while the app is playing a
/// video in audio-only mode in the background: the backend needs the next
/// episode's audio-only URL without any frontend round-trip. Online-only;
/// offline/cache repositories return an error.
///
/// TRACES: UR-040 | JA-032
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+11
View File
@@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode requires the server; offline downloads play locally.
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
+17 -1
View File
@@ -450,7 +450,7 @@ impl OnlineRepository {
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
pub async fn build_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+12 -1
View File
@@ -2055,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
* handoff so the lockscreen MediaSession advertises a real duration a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null }
durationSeconds?: number | null;
/**
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
* background-audio handoff so an episode played as audio-only is still
* recognised as an episode by autoplay (UR-040) and advances to the next one.
*/
itemType?: string | null;
/**
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
* look up the next episode when a background-audio track ends.
*/
seriesId?: string | null }
/**
* Queue context for remote transfer - what type of queue is this?
*/
@@ -4,6 +4,7 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -14,63 +15,12 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
// Check if an episode matches the focused episode (by ID or season/episode number)
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
// Also match by season/episode number in case IDs differ
return ep.parentIndexNumber === episode.parentIndexNumber &&
ep.indexNumber === episode.indexNumber;
return isSameEpisode(ep, episode);
}
// Find adjacent episodes - use season/episode numbers if ID not found
const adjacentEpisodes = $derived(() => {
// First, try to find the episode by ID
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
// If not found by ID, try to find by season/episode number
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
idx = allEpisodes.findIndex(
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
);
}
// If still not found, filter to same season and show those centered around the episode number
if (idx === -1) {
const sameSeasonEpisodes = allEpisodes
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
if (sameSeasonEpisodes.length > 0) {
// Find position based on episode number
const epNum = episode.indexNumber || 1;
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
const start = Math.max(0, actualIdx - 3);
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
const result = sameSeasonEpisodes.slice(start, end);
// Insert the focused episode if not already present (by season/episode number match)
const hasCurrentEpisode = result.some(isCurrentEpisode);
if (!hasCurrentEpisode) {
// Insert at correct position based on episode number
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
if (insertIdx === -1) {
result.push(episode);
} else {
result.splice(insertIdx, 0, episode);
}
}
return result;
}
// Last resort: return focused episode with first 9 episodes
return [episode, ...allEpisodes.slice(0, 9)];
}
// Get 3 before and 6 after (or adjust based on position)
const start = Math.max(0, idx - 3);
const end = Math.min(allEpisodes.length, idx + 7);
return allEpisodes.slice(start, end);
});
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
parentIndexNumber: season,
indexNumber: number,
} as unknown as MediaItem;
}
function season(n: number, count: number): MediaItem[] {
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
}
describe("isCurrentEpisode", () => {
const current = ep("abc", 1, 3);
it("matches by id", () => {
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
});
it("matches by season+episode number when id differs", () => {
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
});
it("does not match a different episode number", () => {
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
});
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
const a = ep("a", null, null);
const b = ep("b", null, null);
expect(isCurrentEpisode(a, b)).toBe(false);
});
it("does not match when only one side has numbers", () => {
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
});
});
describe("adjacentEpisodes", () => {
it("returns just the current episode when there are no others", () => {
const current = ep("only", 1, 1);
expect(adjacentEpisodes(current, [])).toEqual([current]);
});
it("returns siblings, not just the current episode", () => {
const eps = season(1, 8);
const current = eps[2]; // S1E3
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
expect(strip).toContain(current);
});
it("windows to 3 before and 6 after the current episode", () => {
const eps = season(1, 20);
const current = eps[9]; // S1E10, index 9
const strip = adjacentEpisodes(current, eps);
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
const eps = season(1, 5);
// Focused episode has a different id than any in the list but same numbers.
const current = ep("fetched-directly", 1, 3);
const strip = adjacentEpisodes(current, eps);
// It should appear once, anchored at its numeric position, alongside siblings.
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
expect(strip.length).toBeGreaterThan(1);
});
it("falls back to the full list when the current season is unknown", () => {
const eps = season(1, 5);
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
});
});
@@ -0,0 +1,61 @@
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
//
// Extracted from the component so it can be unit-tested: the strip must never
// collapse to just the current episode while real siblings exist, and it must
// not mistake number-less episodes for the current one.
//
// TRACES: UR-048 | DR-062
import type { MediaItem } from "$lib/api/types";
/**
* Does `ep` refer to the same episode as `current`?
*
* Matches by id first. Falls back to season+episode number, but ONLY when both
* numbers are known on both sides otherwise `undefined === undefined` would
* mark every number-less episode as the current one (the bug that made the
* whole strip look like the current episode).
*/
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, so it still anchors the window;
* - returns just `[current]` only when there genuinely are no other episodes.
*/
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
const seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
return pool.slice(start, end);
}
+6 -1
View File
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -1233,6 +1234,10 @@
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
// Episode identity so the backend can auto-advance to the next episode
// when this audio-only stream ends while backgrounded (UR-040).
itemType: media.type ?? null,
seriesId: media.seriesId ?? null,
},
pos,
);
@@ -1591,7 +1596,7 @@
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full"
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
+49
View File
@@ -0,0 +1,49 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
@@ -53,7 +53,7 @@
<MediaCard
{item}
size="medium"
showProgress={group.id !== "artists"}
showProgress={group.id !== "artists" && group.id !== "people"}
onclick={() => onItemClick?.(item)}
/>
{/each}
+88
View File
@@ -0,0 +1,88 @@
/**
* Skip-to-next-episode reporting tests.
*
* Regression: pressing "skip to next episode" left the outgoing episode with a
* mid-episode resume position, so it showed a partial progress bar and offered
* to resume. A manual skip means the user is done with that episode it must
* be recorded as fully watched.
*
* TRACES: UR-059, UR-025 | DR-088
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
const reportPlaybackStopped = vi.fn(
async (_itemId: string, _positionSeconds: number) => undefined
);
vi.mock("./playbackReporting", () => ({
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
reportPlaybackStopped: (itemId: string, positionSeconds: number) =>
reportPlaybackStopped(itemId, positionSeconds),
}));
import {
shouldSuppressStopReport,
markSkipped,
reportSkippedEpisode,
resetSkipState,
} from "./skipReporting";
describe("skip reporting", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSkipState();
});
describe("reportSkippedEpisode", () => {
it("marks the skipped episode as fully played", async () => {
await reportSkippedEpisode("ep-1");
expect(markAsPlayed).toHaveBeenCalledWith("ep-1");
});
it("does not stamp the mid-episode position as a resume point", async () => {
await reportSkippedEpisode("ep-1");
expect(reportPlaybackStopped).not.toHaveBeenCalled();
});
it("ignores a null item id", async () => {
await reportSkippedEpisode(null);
expect(markAsPlayed).not.toHaveBeenCalled();
});
});
describe("shouldSuppressStopReport", () => {
it("suppresses the unmount stop report for the skipped episode", async () => {
await reportSkippedEpisode("ep-1");
// VideoPlayer.onDestroy fires after navigation with the mid-episode time.
expect(shouldSuppressStopReport("ep-1")).toBe(true);
});
it("only suppresses the episode that was actually skipped", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-2")).toBe(false);
});
it("suppresses only once, so a later real stop still reports", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-1")).toBe(true);
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress when nothing was skipped", () => {
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress a null item id", () => {
markSkipped("ep-1");
expect(shouldSuppressStopReport(null)).toBe(false);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
// Skip-to-next-episode reporting.
//
// Skipping an episode is a "done with it" signal, not a "stopped here" one:
// the user is moving on because they've already seen it. So a manual skip
// records the outgoing episode as fully played rather than saving the
// mid-episode position as a resume point.
//
// The suppression handshake exists because VideoPlayer.onDestroy fires its
// final reportStop *after* the skip navigation, with the mid-episode time. If
// that landed, it would overwrite the just-written 100% progress and the
// episode would look partially watched again. markSkipped() arms a one-shot
// suppression that the stop handler consumes.
//
// TRACES: UR-059, UR-025 | DR-088
import { markAsPlayed, reportPlaybackStopped } from "./playbackReporting";
/** Item id whose next stop report should be dropped, if any. */
let suppressedItemId: string | null = null;
/**
* Arm suppression of the next stop report for `itemId`.
*
* Exported separately from `reportSkippedEpisode` so callers that already
* handled their own reporting can still silence the unmount stop.
*/
export function markSkipped(itemId: string | null): void {
if (!itemId) return;
suppressedItemId = itemId;
}
/**
* Should the pending stop report for `itemId` be dropped?
*
* One-shot: consumes the armed suppression, so a later genuine stop on the
* same episode still reports its position normally.
*/
export function shouldSuppressStopReport(itemId: string | null): boolean {
if (!itemId) return false;
if (suppressedItemId !== itemId) return false;
suppressedItemId = null;
return true;
}
/**
* Record a manually skipped episode as fully watched.
*
* Deliberately does NOT call `reportPlaybackStopped` that would write the
* partial position we are trying to avoid.
*/
export async function reportSkippedEpisode(itemId: string | null): Promise<void> {
if (!itemId) return;
markSkipped(itemId);
await markAsPlayed(itemId);
}
/** Test hook: clear armed suppression between cases. */
export function resetSkipState(): void {
suppressedItemId = null;
}
// Re-exported so the module owns the full skip story; callers that need the
// normal stop path keep importing it from playbackReporting directly.
export { reportPlaybackStopped };
@@ -0,0 +1,124 @@
/**
* Continue Watching stale-entry suppression tests.
*
* A partially-watched episode should drop off Continue Watching once the user
* has moved past it i.e. when Next Up for that series points at a *later*
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
function episode(
id: string,
seriesId: string,
season: number | undefined,
index: number | undefined
): MediaItem {
return {
id,
name: `Episode ${index}`,
kind: "episode",
seriesId,
parentIndexNumber: season,
indexNumber: index,
} as MediaItem;
}
function movie(id: string): MediaItem {
return { id, name: "A Movie", kind: "movie" } as MediaItem;
}
describe("filterSupersededResumeItems", () => {
it("drops a partially-watched episode when next up is later in the same season", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result).toEqual([]);
});
it("drops it when next up is in a later season", () => {
const resume = [episode("s1e9", "series-a", 1, 9)];
const nextUp = [episode("s2e1", "series-a", 2, 1)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("keeps the episode the user is actually mid-way through", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps an episode ahead of next up (user jumped forward)", () => {
const resume = [episode("s1e7", "series-a", 1, 7)];
const nextUp = [episode("s1e3", "series-a", 1, 3)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("only compares within the same series", () => {
const resume = [episode("a-s1e2", "series-a", 1, 2)];
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("never suppresses movies", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps items when ordering is unknown on either side", () => {
const resume = [episode("s1e2", "series-a", undefined, undefined)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("treats a missing season number as season 1 only when both sides agree", () => {
// Flat series (no season folders): episode numbers alone must still order.
const resume = [episode("e2", "series-a", undefined, 2)];
const nextUp = [episode("e6", "series-a", undefined, 6)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("is a no-op when next up is empty", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
});
it("preserves the original order of surviving items", () => {
const resume = [
episode("a-s1e2", "series-a", 1, 2),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s1e3", "series-c", 1, 3),
];
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
});
it("uses the furthest-ahead next-up entry for a series", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [
episode("s1e1", "series-a", 1, 1),
episode("s1e8", "series-a", 1, 8),
];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// Continue Watching stale-entry suppression.
//
// Continue Watching is built from raw resume positions, so an episode the user
// has moved past keeps showing up as a resume suggestion — most visibly after
// skipping an episode, which leaves a partial position behind. Next Up already
// tells us where the user actually is in each series, so an in-progress episode
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
//
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
import type { MediaItem } from "$lib/api/types";
/**
* Position of an episode within its series, as (season, episode).
*
* Returns null when the episode number is unknown without it there is no
* defensible ordering and we must not suppress anything. A missing *season*
* number is normal for flat series (no season folders), so it is only usable
* when both sides are equally season-less; callers compare via `isAheadOf`.
*/
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
if (item.indexNumber == null) return null;
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
}
/** Is `a` strictly later in series order than `b`? */
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
const oa = episodeOrder(a);
const ob = episodeOrder(b);
if (!oa || !ob) return false;
// Mixed season-numbering (one side foldered, the other flat) is not safely
// comparable — leave the entry alone rather than hide something wrongly.
if ((oa.season == null) !== (ob.season == null)) return false;
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
return oa.season > ob.season;
}
return oa.index > ob.index;
}
/**
* Drop resume entries the user has already moved past.
*
* An episode is suppressed when its series has a Next Up entry strictly later
* in series order. Movies, items without a series, and anything whose ordering
* is unknown are always kept suppression must never hide something the user
* genuinely still wants to resume.
*/
export function filterSupersededResumeItems(
resumeItems: MediaItem[],
nextUpItems: MediaItem[]
): MediaItem[] {
if (nextUpItems.length === 0) return resumeItems;
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
// entry for a series, and the latest is the true watch frontier.
const frontier = new Map<string, MediaItem>();
for (const item of nextUpItems) {
if (!item.seriesId) continue;
const current = frontier.get(item.seriesId);
if (!current || isAheadOf(item, current)) {
frontier.set(item.seriesId, item);
}
}
return resumeItems.filter(item => {
if (item.kind !== "episode" || !item.seriesId) return true;
const ahead = frontier.get(item.seriesId);
if (!ahead) return true;
return !isAheadOf(ahead, item);
});
}
+7 -2
View File
@@ -1,8 +1,9 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -50,8 +51,12 @@ function createHomeStore() {
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+43 -26
View File
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"episodes",
"shows",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
it("migrates a stored `tvShows` from before the group split", async () => {
// Upgrading must keep the user's placement of TV, not append the two new
// groups at the bottom.
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Default is shows, episodes, movies, songs, … — move movies up one.
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
const expected = [
"shows",
"movies",
"episodes",
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
"people",
];
expect(get(searchGroupOrder)).toEqual(expected);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Drag "albums" (index 4) to the front.
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"shows",
"episodes",
"movies",
"songs",
"artists",
"people",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.move("movies", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
});
+8 -2
View File
@@ -1,10 +1,11 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -81,7 +82,12 @@ function createTvStore() {
// 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.kind === "episode" || i.kind === "movie");
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+158 -30
View File
@@ -7,6 +7,8 @@ import {
normalizeGroupOrder,
reorderGroups,
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
scopeItemTypes,
type SearchGroupId,
} from "./searchScope";
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs",
"movies",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
it("preserves a complete valid order unchanged", () => {
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
const order: SearchGroupId[] = [
"episodes",
"shows",
"movies",
"artists",
"albums",
"songs",
"people",
];
expect(normalizeGroupOrder(order)).toEqual(order);
});
it("expands a stored `tvShows` into shows + episodes in place", () => {
// Migration: the old combined group split, and a user who put TV first
// must still get TV first rather than appended at the bottom.
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
});
describe("groupsForScope", () => {
it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
"movies",
"songs",
"tvShows",
"albums",
"artists",
]);
expect(
groupsForScope("all", [
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"people",
])
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
});
it("keeps only in-scope groups, in saved order", () => {
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
const order: SearchGroupId[] = [
"artists",
"movies",
"albums",
"episodes",
"shows",
"songs",
"people",
];
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
expect(groupsForScope("movies", order)).toEqual(["movies"]);
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
});
it("surfaces people only under the all scope", () => {
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
});
});
@@ -156,13 +206,29 @@ describe("composeSearchGroups", () => {
it("renders groups in the configured order", () => {
const groups = composeSearchGroups(results, "all", [
"tvShows",
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
expect(groups.map((g) => g.id)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"people",
]);
});
it("puts shows ahead of episodes by default", () => {
// Searching a show's name should surface the show itself first, not an
// arbitrary episode of it.
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
expect(ids).toEqual(["shows", "episodes"]);
});
it("omits empty groups", () => {
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
"albums",
]);
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"tvShows",
"shows",
"episodes",
]);
});
it("groups series and episodes together under tvShows", () => {
it("separates series and episodes into their own groups", () => {
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
});
it("surfaces people so an actor search reaches their bio", () => {
// Person items were previously returned by the backend and silently dropped.
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
});
it("ignores item types that belong to no group", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
});
it("narrowing then widening restores the full arrangement", () => {
// Scope is a filter over the saved order, never a rewrite of it.
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
const order: SearchGroupId[] = [
"shows",
"songs",
"movies",
"albums",
"artists",
"episodes",
"people",
];
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
composeSearchGroups(results, "music", order);
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
});
it("survives a stored order containing an unknown id", () => {
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
"podcasts",
"movies",
] as unknown as SearchGroupId[]);
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
expect(groups.map((g) => g.id)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"people",
]);
});
it("handles items with a missing type", () => {
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
});
describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
"artists",
"albums",
"movies",
"tvShows",
"shows",
]);
});
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
"songs",
"artists",
"movies",
"tvShows",
"shows",
]);
});
it("is a no-op at the boundaries", () => {
expect(moveGroup(order, "songs", -1)).toEqual(order);
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
expect(moveGroup(order, "shows", 1)).toEqual(order);
});
it("is a no-op for an unknown id", () => {
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
});
describe("reorderGroups", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves an item from one index to another", () => {
expect(reorderGroups(order, 0, 4)).toEqual([
"albums",
"artists",
"movies",
"tvShows",
"shows",
"songs",
]);
expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows",
"shows",
"songs",
"albums",
"artists",
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
expect(reorderGroups(order, 0, 9)).toEqual(order);
});
});
describe("searchRouteUrl", () => {
it("encodes the query and the scope", () => {
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
});
it("omits the scope key for the default `all` scope", () => {
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
});
it("targets bare /search for an empty query so the page shows its empty state", () => {
expect(searchRouteUrl("", "all")).toBe("/search");
expect(searchRouteUrl(" ", "music")).toBe("/search");
});
});
describe("shouldNavigateToSearch", () => {
it("navigates from any library page, which cannot render results itself", () => {
// The bug: the header search bar shows on every /library/** route but only
// /library rendered $library.searchResults, so typing did nothing on
// /library/music, /library/tv, /library/movies and detail pages.
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
});
it("stays put when already on /search, so typing does not re-push history", () => {
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
});
it("does not navigate on an empty query", () => {
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
});
});
+108 -17
View File
@@ -62,51 +62,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
return "all";
}
/**
* The URL of the single search surface for a query + scope.
*
* `/search` is the *only* route that renders results, so every other search
* affordance (the desktop header bar) is a navigator to this URL rather than a
* second result renderer. The `all` scope is the page's own default, so it is
* omitted to keep shared/back-navigated URLs clean.
*
* TRACES: UR-049 | DR-063
*/
export function searchRouteUrl(query: string, scope: SearchScope): string {
const trimmed = query.trim();
if (!trimmed) return "/search";
const params = new URLSearchParams({ q: trimmed });
if (scope !== "all") params.set("scope", scope);
// URLSearchParams renders spaces as "+", valid in a query but noisier to
// read; %20 is equally valid and matches how the app builds other links.
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
* True for every route except `/search` itself: no other page renders
* `searchResults`, so a search performed there is invisible. Guarding on
* `/search` keeps typing from pushing a history entry per keystroke.
*
* TRACES: UR-049 | DR-063
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
}
// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
export type SearchGroupId =
| "shows"
| "episodes"
| "movies"
| "songs"
| "albums"
| "artists"
| "people";
/** Shipped default order, per the spec. */
/**
* Shipped default order.
*
* TRACES: UR-060 | DR-091
*
* Containers lead the kinds they contain a show above its episodes, an album
* above nothing (songs are ranked separately) which matches how people search:
* you look for the show, not an arbitrary episode of it. `people` sits last as
* a peripheral match; it exists so searching an actor's name reaches their bio
* page rather than silently dropping the result.
*/
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"movies",
"tvShows",
"people",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs",
albums: "Albums",
artists: "Artists",
movies: "Movies",
tvShows: "TV Shows",
people: "People",
};
/** Which scopes each group belongs to (`all` always includes everything). */
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
/**
* Which scopes each group belongs to (`all` always includes everything).
*
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
* it surfaces only under All rather than being forced into one of them.
*/
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
shows: "tv",
episodes: "tv",
movies: "movies",
songs: "music",
albums: "music",
artists: "music",
movies: "movies",
tvShows: "tv",
people: null,
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
movies: ["Movie"],
tvShows: ["Series", "Episode"],
people: ["Person"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]];
}
/**
* Stored group ids that no longer exist, mapped to the ids that replaced them.
*
* `tvShows` was one group holding both Series and Episode; it split so a show
* can outrank its own episodes. Expanding in place preserves the position the
* user chose for it.
*
* TRACES: UR-060 | DR-091
*/
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
tvShows: ["shows", "episodes"],
};
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
if (known.has(id)) return [id as SearchGroupId];
return RETIRED_GROUP_IDS[id] ?? [];
}
/**
* Normalise a stored order into a usable one.
*
@@ -123,11 +208,15 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue;
const groupId = id as SearchGroupId;
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
if (typeof id !== "string") continue;
// Retired ids expand in place rather than being dropped, so a user who
// dragged the old combined "TV Shows" group to the top keeps TV at the
// top instead of having shows/episodes appended to the bottom.
for (const groupId of migrateGroupId(id, known)) {
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
}
}
}
@@ -143,6 +232,8 @@ export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
// only under `all` — the `=== scope` test already excludes it elsewhere.
return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
+20 -14
View File
@@ -6,8 +6,12 @@
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -48,18 +52,22 @@
}
});
// The header bar is a *navigator*, not a second results surface: /search is
// the only route that renders searchResults, so searching here routes there
// with the query + route-derived scope in the URL. Previously this ran
// library.search() in place, which was invisible on every /library/** page
// except /library itself.
// TRACES: UR-049 | DR-063
async function handleSearch(query: string) {
if (query.trim()) {
await library.search(query, searchScope);
} else {
if (!query.trim()) {
library.clearSearch();
return;
}
}
async function handleScopeChange(next: SearchScope) {
searchScope = next;
if (searchQuery.trim()) {
await library.search(searchQuery, next);
if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
// The query now lives in the URL; clear the header input so returning to
// a library page does not leave a stale term sitting in the box.
searchQuery = "";
}
}
</script>
@@ -74,14 +82,12 @@
<AppHeader search={librarySearch} />
{#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
{#if searchQuery.trim()}
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
{/if}
{/snippet}
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
+4 -24
View File
@@ -12,8 +12,9 @@
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery);
// Search results are rendered exclusively by /search — this page used to
// render them inline, which made the header search bar appear broken on every
// other /library/** route. TRACES: UR-049 | DR-063
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
@@ -169,28 +170,7 @@
</script>
<div class="space-y-8">
{#if searchQuery}
<!-- Search results -->
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-white">
Search results for "{searchQuery}"
</h1>
<button
onclick={() => library.clearSearch()}
class="text-sm text-gray-400 hover:text-white"
>
Clear search
</button>
</div>
<LibraryGrid
items={searchResults}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else if showInlineLibraryContent}
{#if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6">
<div class="flex items-center gap-4">
+28
View File
@@ -147,6 +147,34 @@
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
+14 -1
View File
@@ -20,6 +20,7 @@
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -536,7 +537,11 @@
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
// A skipped episode was already recorded as fully watched. Its unmount stop
// report arrives after the skip navigation carrying the mid-episode
// position; letting it through would undo that and restore the partial
// progress bar.
if (id && !shouldSuppressStopReport(id)) {
reportPlaybackStopped(id, positionSeconds);
}
// Intentionally do NOT emit a "stopped" player state here. This runs on both
@@ -592,6 +597,14 @@
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
// fully watched rather than leaving a mid-episode resume point behind. This
// also arms suppression of the VideoPlayer's unmount stop report, which
// would otherwise fire after navigation and overwrite the 100% progress
// with the partial position (see skipReporting.ts).
const skippedId = currentMedia?.id ?? itemId ?? null;
void reportSkippedEpisode(skippedId);
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
+29 -3
View File
@@ -5,15 +5,41 @@
import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
import type { MediaItem } from "$lib/api/types";
let searchQuery = $state("");
// `?q=` / `?scope=` seed the page so the desktop header search bar can hand
// a query over by navigating here — /search is the only surface that renders
// results, so every other search affordance routes into it.
// TRACES: UR-049 | DR-063
const initialQuery = $page.url.searchParams.get("q") ?? "";
const initialScope = $page.url.searchParams.get("scope");
let searchQuery = $state(initialQuery);
// Route resolves the *initial* scope only. Deriving it reactively would snap
// a user who widened to All back to the route's scope on any navigation.
// TRACES: UR-049 | DR-064
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
let scope = $state<SearchScope>(
SEARCH_SCOPES.includes(initialScope as SearchScope)
? (initialScope as SearchScope)
: resolveSearchScope($page.url.pathname)
);
// A query arriving in the URL must actually run — mounting with a seeded
// input alone would render the empty state with a filled box.
$effect(() => {
const q = $page.url.searchParams.get("q") ?? "";
if (!q.trim()) return;
const urlScope = $page.url.searchParams.get("scope");
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
? (urlScope as SearchScope)
: "all";
if (q === $library.searchQuery && nextScope === scope) return;
searchQuery = q;
scope = nextScope;
library.search(q, nextScope);
});
async function handleSearch(query: string) {
if (query.trim()) {