Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ffabee06 | ||
|
|
13e0860401 | ||
|
|
d1c01a6bc3 | ||
|
|
e5d3cc06f2 | ||
|
|
5759a97289 | ||
|
|
b9f026e215 | ||
|
|
b7a7037194 | ||
|
|
124da29fc7 | ||
|
|
5927299c0f | ||
|
|
7650efcb7f | ||
|
|
4b9350c949 | ||
|
|
d01c1216b8 | ||
|
|
fb967433f0 | ||
|
|
ee584aced2 | ||
|
|
eb76c96e94 |
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
|
|||||||
/docs/README.md
|
/docs/README.md
|
||||||
/docs/api-redirect.md
|
/docs/api-redirect.md
|
||||||
/docs-site/book/
|
/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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -266,6 +266,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
|||||||
|
|
||||||
## Testing
|
## 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
|
```bash
|
||||||
# Rust
|
# Rust
|
||||||
cd src-tauri && cargo test
|
cd src-tauri && cargo test
|
||||||
|
|||||||
+16
-1
@@ -69,6 +69,9 @@ 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-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-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-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 |
|
||||||
|
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,7 +223,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-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-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-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-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-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 |
|
| 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 +242,11 @@ 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-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-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-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 |
|
||||||
|
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -306,6 +314,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087 |
|
| UR-058 | - | DR-087 |
|
||||||
|
| UR-060 | - | DR-090, DR-091 |
|
||||||
|
| UR-061 | - | DR-092 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -376,6 +386,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||||
|
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||||||
@@ -395,6 +406,10 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||||
|
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
||||||
|
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
|
||||||
|
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
|
||||||
|
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+689
-477
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -346,10 +346,12 @@ flowchart TB
|
|||||||
**User Interaction:**
|
**User Interaction:**
|
||||||
- **Tap screen:** Controls reappear for 3 seconds
|
- **Tap screen:** Controls reappear for 3 seconds
|
||||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
|
||||||
|
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
|
||||||
|
skips without also toggling pause (UR-061)
|
||||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
|
||||||
- **Keyboard space/K:** Toggle play/pause
|
- **Keyboard space/K:** Toggle play/pause
|
||||||
- **Keyboard F:** Toggle fullscreen
|
- **Keyboard F:** Toggle fullscreen
|
||||||
- **Pinch:** Zoom (planned)
|
- **Pinch:** Zoom (planned)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.0"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.0"
|
version = "0.1.5"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package com.dtourolle.jellytau
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
|||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var configAttempts = 0
|
private var configAttempts = 0
|
||||||
private val maxConfigAttempts = 10
|
private val maxConfigAttempts = 10
|
||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
|
||||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||||
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private var mediaWebView: WebView? = null
|
private var mediaWebView: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebView the @JavascriptInterface bridges have been injected into.
|
||||||
|
*
|
||||||
|
* addJavascriptInterface must run once per WebView instance: re-injecting
|
||||||
|
* over an already-loaded page hands JS a stale proxy whose methods are gone.
|
||||||
|
* Compared by identity so a genuinely new WebView still gets its bridges.
|
||||||
|
*/
|
||||||
|
private var bridgesInstalledOn: WebView? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
|
|||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||||
webView.addJavascriptInterface(object : Any() {
|
//
|
||||||
@JavascriptInterface
|
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||||
fun requestAudioFocus() {
|
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||||
handler.post { this@MainActivity.requestAudioFocus() }
|
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
||||||
}
|
// injected objects at page-load time; re-injecting over a live page
|
||||||
|
// leaves JS holding a stale proxy. The object stays truthy while its
|
||||||
|
// methods vanish, which surfaced as a flood of
|
||||||
|
// "WebView: Unknown object" chromium errors and, in JS,
|
||||||
|
// "TypeError: setEnabled is not a function".
|
||||||
|
//
|
||||||
|
// The visible bug: the background-audio toggle turned blue but never
|
||||||
|
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
||||||
|
// dispatched 'jellytau-background', and a locked screen killed audio
|
||||||
|
// instantly (UR-040). Audio focus and PiP broke the same way.
|
||||||
|
//
|
||||||
|
// The settings/WebChromeClient work below is idempotent and must keep
|
||||||
|
// running on resume; only the bridge injection is one-shot.
|
||||||
|
if (webView === bridgesInstalledOn) {
|
||||||
|
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bridgesInstalledOn = webView
|
||||||
|
|
||||||
@JavascriptInterface
|
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||||
fun abandonAudioFocus() {
|
// requests from the WebView competed with Chromium's own
|
||||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||||
}
|
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||||
}, "AndroidAudioFocus")
|
// in configureWebViewSettings().
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
|
||||||
|
|
||||||
// Add JavaScript interface for picture-in-picture control.
|
// Add JavaScript interface for picture-in-picture control.
|
||||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||||
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
|
|||||||
backgroundAudioEnabled = enabled
|
backgroundAudioEnabled = enabled
|
||||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
|
||||||
@JavascriptInterface
|
|
||||||
fun isSupported(): Boolean = true
|
|
||||||
}, "AndroidBackgroundAudio")
|
}, "AndroidBackgroundAudio")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||||
|
|
||||||
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
|
|||||||
dispatchWebEvent("jellytau-network-changed")
|
dispatchWebEvent("jellytau-network-changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebView settings, chrome client and the video-unmute script.
|
||||||
|
*
|
||||||
|
* Split out from the bridge injection because this half is idempotent and
|
||||||
|
* must re-run on every resume, whereas addJavascriptInterface must not.
|
||||||
|
*/
|
||||||
|
private fun configureWebViewSettings(webView: WebView) {
|
||||||
|
try {
|
||||||
// Set WebChromeClient to handle video playback and audio focus
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onHideCustomView()
|
super.onHideCustomView()
|
||||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
|
||||||
|
*
|
||||||
|
* Without this the frontend is invisible to `adb logcat`, which makes
|
||||||
|
* diagnosing anything that spans the JS/native boundary (the
|
||||||
|
* background-audio handoff in particular) guesswork.
|
||||||
|
*/
|
||||||
|
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauWeb",
|
||||||
|
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||||
|
|
||||||
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
|
|||||||
video.volume = 1.0;
|
video.volume = 1.0;
|
||||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||||
|
|
||||||
// Add event listeners to manage audio focus
|
// NOTE: deliberately no audio-focus calls here.
|
||||||
video.addEventListener('play', function() {
|
//
|
||||||
console.log('[Android] Video play event - requesting audio focus');
|
// WebView already manages audio focus for <video> through
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||||
AndroidAudioFocus.requestAudioFocus();
|
// again from MainActivity made two requesters compete inside one
|
||||||
}
|
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
// (~45ms), whose handler paused playback - so arming background
|
||||||
});
|
// audio, or simply pressing play, paused the video in a loop.
|
||||||
|
//
|
||||||
video.addEventListener('pause', function() {
|
// ExoPlayer is the third potential owner and stays authoritative
|
||||||
console.log('[Android] Video pause event - abandoning audio focus');
|
// for native playback (JellyTauPlayer manages its own focus).
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Leave focus to whichever engine is actually rendering.
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('ended', function() {
|
|
||||||
console.log('[Android] Video ended event - abandoning audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('volumechange', function() {
|
video.addEventListener('volumechange', function() {
|
||||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||||
});
|
});
|
||||||
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
||||||
.setAudioAttributes(audioAttributes)
|
|
||||||
.setAcceptsDelayedFocusGain(true)
|
|
||||||
.setOnAudioFocusChangeListener { focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val result = audioManager.requestAudioFocus(
|
|
||||||
{ focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
},
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
AudioManager.AUDIOFOCUS_GAIN
|
|
||||||
)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abandonAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
audioFocusRequest?.let {
|
|
||||||
audioManager.abandonAudioFocusRequest(it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
audioManager.abandonAudioFocus { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
|
|||||||
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub duration_seconds: Option<f64>,
|
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?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
|
|||||||
artists: None,
|
artists: None,
|
||||||
primary_image_tag: item.primary_image_tag.clone(),
|
primary_image_tag: item.primary_image_tag.clone(),
|
||||||
image_id: 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,
|
playlist_id: None,
|
||||||
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
||||||
duration: item.duration_seconds,
|
duration: item.duration_seconds,
|
||||||
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
|
|||||||
video_width: None,
|
video_width: None,
|
||||||
video_height: None,
|
video_height: None,
|
||||||
subtitles: vec![],
|
subtitles: vec![],
|
||||||
series_id: None,
|
series_id: item.series_id.clone(),
|
||||||
server_id: item.server_id.clone(),
|
server_id: item.server_id.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::rank_search_results;
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
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
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||||
// UI can render immediately while the server is still being queried.
|
// 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())
|
.search_cache_only(&query, options.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.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,
|
// 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
|
// and push the union to the frontend via a `search-event`. Tagged with
|
||||||
// `request_id` so the frontend can discard results from superseded queries.
|
// `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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
match repo_bg.search_server_only(&query, options).await {
|
match repo_bg.search_server_only(&query, options).await {
|
||||||
Ok(server_result) => {
|
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 {
|
let event = SearchUpdateEvent {
|
||||||
request_id,
|
request_id,
|
||||||
result: merged,
|
result: merged,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
pub mod from_jellyfin;
|
pub mod from_jellyfin;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod search_rank;
|
||||||
|
|
||||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||||
pub use media::{MediaKind, StreamKind};
|
pub use media::{MediaKind, StreamKind};
|
||||||
|
pub use search_rank::rank_search_results;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -858,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start countdown if auto_advance enabled
|
|
||||||
if auto_advance {
|
if auto_advance {
|
||||||
controller
|
// Background audio-only episode: the frontend that normally
|
||||||
.lock()
|
// performs the advance (goto /player/<id>) is suspended, so
|
||||||
.await
|
// the backend must load the next episode's audio-only stream
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
// 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) => {
|
Err(e) => {
|
||||||
|
|||||||
+271
-10
@@ -243,6 +243,23 @@ impl PlayerController {
|
|||||||
self.end_reason.lock_safe().take()
|
self.end_reason.lock_safe().take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that playback is being stopped by an expiring sleep timer.
|
||||||
|
///
|
||||||
|
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||||
|
/// Android), which lands in `on_playback_ended`. Without an end reason that
|
||||||
|
/// reads as a natural finish and autoplay advances — defeating the timer.
|
||||||
|
/// `UserStop` is the honest label: the stop was user-initiated, just via the
|
||||||
|
/// timer they set rather than the stop button.
|
||||||
|
///
|
||||||
|
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
|
||||||
|
/// which owns clones, not the controller — records it the same way.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026 | DR-029
|
||||||
|
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
|
||||||
|
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
|
||||||
|
*end_reason.lock_safe() = Some(EndReason::UserStop);
|
||||||
|
}
|
||||||
|
|
||||||
/// Increment autoplay episode counter. Returns true if limit is reached.
|
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||||
fn increment_autoplay_count(&self) -> bool {
|
fn increment_autoplay_count(&self) -> bool {
|
||||||
let max = self.autoplay_settings.lock_safe().max_episodes;
|
let max = self.autoplay_settings.lock_safe().max_episodes;
|
||||||
@@ -658,6 +675,24 @@ impl PlayerController {
|
|||||||
self.queue.clone()
|
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
|
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
|
||||||
/// sleep timer fires or the queue ends with repeat off). Pair with
|
/// sleep timer fires or the queue ends with repeat off). Pair with
|
||||||
/// `emit_queue_changed` so the frontend hides the mini player.
|
/// `emit_queue_changed` so the frontend hides the mini player.
|
||||||
@@ -749,6 +784,7 @@ impl PlayerController {
|
|||||||
let sleep_timer = self.sleep_timer.clone();
|
let sleep_timer = self.sleep_timer.clone();
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
|
let end_reason = self.end_reason.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
@@ -765,6 +801,14 @@ impl PlayerController {
|
|||||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
|
|
||||||
|
// Mark the stop *before* it reaches the backend. Stopping
|
||||||
|
// makes the native player fire its ended callback, and
|
||||||
|
// cancelling the timer above means on_playback_ended can no
|
||||||
|
// longer tell this apart from a natural end — without this
|
||||||
|
// it would show the next-episode popup / autoplay right
|
||||||
|
// after the sleep timer fired.
|
||||||
|
Self::note_sleep_timer_stop(&end_reason);
|
||||||
|
|
||||||
// Emit cancelled state
|
// Emit cancelled state
|
||||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
@@ -963,9 +1007,11 @@ impl PlayerController {
|
|||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
SleepTimerMode::Episodes { .. } => {
|
SleepTimerMode::Episodes { .. } => {
|
||||||
// Only count TV episodes (not audio tracks or movies)
|
// Only count TV episodes (not audio tracks or movies). Note an
|
||||||
let is_episode =
|
// episode played in background-audio mode is MediaType::Audio, so
|
||||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
// rely on is_episode_item (which checks item_type) rather than the
|
||||||
|
// media_type alone.
|
||||||
|
let is_episode = self.is_episode_item(¤t).await;
|
||||||
|
|
||||||
if is_episode {
|
if is_episode {
|
||||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||||
@@ -981,10 +1027,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).
|
// 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.
|
// It's here for the Android ExoPlayer path where episode items sit in the
|
||||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
// 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(¤t).await {
|
||||||
let repo = self.repository.lock_safe().clone();
|
let repo = self.repository.lock_safe().clone();
|
||||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||||
let next_ep_result = if let Some(repo) = &repo {
|
let next_ep_result = if let Some(repo) = &repo {
|
||||||
@@ -1042,6 +1090,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.
|
/// Handle video playback ended from HTML5 video element.
|
||||||
///
|
///
|
||||||
/// HTML5 video plays independently of the Rust backend, so the backend
|
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||||
@@ -1135,11 +1254,19 @@ impl PlayerController {
|
|||||||
Ok(AutoplayDecision::Stop)
|
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 {
|
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||||
// For now, assume video items are episodes
|
match item.item_type.as_deref() {
|
||||||
// In production, we'd check item metadata or query Jellyfin
|
Some("Episode") => true,
|
||||||
item.media_type == MediaType::Video
|
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
|
/// Fetch next episode for a series by looking up the season's episodes
|
||||||
@@ -1939,6 +2066,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A time-based sleep timer that fires mid-episode must not let the ended
|
||||||
|
/// callback fall through to autoplay.
|
||||||
|
///
|
||||||
|
/// The timer thread stops the backend directly, which makes ExoPlayer emit
|
||||||
|
/// its ended callback. That callback races the thread's own `timer.cancel()`:
|
||||||
|
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
|
||||||
|
/// so the timer branch is skipped and the episode path runs — showing a
|
||||||
|
/// next-episode popup (or advancing) after the user's sleep timer expired.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let items = create_test_items(3);
|
||||||
|
controller.play_queue(items, 0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
// Arm a time-based timer that is already due, then let the real timer
|
||||||
|
// thread (started in the constructor, 1s tick) observe the expiry and
|
||||||
|
// run its stop path. Driving the actual thread is the point: the bug was
|
||||||
|
// that this path stopped the backend without recording an end reason.
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
|
||||||
|
|
||||||
|
// Wait for the timer thread to process the expiry (tick is 1s).
|
||||||
|
for _ in 0..40 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
if !controller.sleep_timer.lock_safe().is_active() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!controller.sleep_timer.lock_safe().is_active(),
|
||||||
|
"Timer thread should have expired and cancelled the sleep timer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The backend stop above makes the native player fire its ended callback.
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::Stop),
|
||||||
|
"Expected Stop after an expired time-based sleep timer, got {:?}",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_empty_queue_stops() {
|
async fn test_empty_queue_stops() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
@@ -2311,6 +2483,15 @@ mod tests {
|
|||||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||||
unimplemented!()
|
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(
|
async fn get_live_tv_channels(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||||
@@ -2503,6 +2684,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 ¤t.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
|
/// Without a controller repository the Android episode path must still
|
||||||
/// stop gracefully (previous behavior) rather than error.
|
/// stop gracefully (previous behavior) rather than error.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.get_audio_stream_url(item_id).await
|
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> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV requires server communication - delegate to online repository
|
// Live TV requires server communication - delegate to online repository
|
||||||
self.online.get_live_tv_channels().await
|
self.online.get_live_tv_channels().await
|
||||||
@@ -1028,6 +1046,16 @@ mod tests {
|
|||||||
unimplemented!()
|
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> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1276,6 +1304,16 @@ mod tests {
|
|||||||
unimplemented!()
|
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> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// @req: JA-007 - Get playback info and stream URL
|
/// @req: JA-007 - Get playback info and stream URL
|
||||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
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.
|
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
||||||
|
|
||||||
|
|||||||
@@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
|
|||||||
Err(RepoError::Offline)
|
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> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV is inherently online-only.
|
// Live TV is inherently online-only.
|
||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ impl OnlineRepository {
|
|||||||
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
||||||
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
||||||
/// decodable and supports mid-stream `StartTimeTicks`.
|
/// 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,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(url)
|
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> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
||||||
// type "TvChannel" — playable via open_live_stream.
|
// type "TvChannel" — playable via open_live_stream.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+12
-1
@@ -2055,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
|
|||||||
* handoff so the lockscreen MediaSession advertises a real duration — a
|
* handoff so the lockscreen MediaSession advertises a real duration — a
|
||||||
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
* 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?
|
* Queue context for remote transfer - what type of queue is this?
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
@@ -14,63 +15,12 @@
|
|||||||
|
|
||||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
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 {
|
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||||
if (ep.id === episode.id) return true;
|
return isSameEpisode(ep, episode);
|
||||||
// Also match by season/episode number in case IDs differ
|
|
||||||
return ep.parentIndexNumber === episode.parentIndexNumber &&
|
|
||||||
ep.indexNumber === episode.indexNumber;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find adjacent episodes - use season/episode numbers if ID not found
|
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compute best backdrop source (no fetch, pure derivation)
|
// Compute best backdrop source (no fetch, pure derivation)
|
||||||
const backdropSource = $derived.by(() => {
|
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, untrack } from "svelte";
|
import { onMount, onDestroy, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import SleepTimerModal from "./SleepTimerModal.svelte";
|
import SleepTimerModal from "./SleepTimerModal.svelte";
|
||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
|
import { videoFitClass } from "./videoFit";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import { playbackPosition } from "$lib/stores/player";
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
@@ -19,6 +20,14 @@
|
|||||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||||
|
import {
|
||||||
|
createTapGestureState,
|
||||||
|
registerTap,
|
||||||
|
resolveSeekTarget,
|
||||||
|
SEEK_FORWARD_SECONDS,
|
||||||
|
SEEK_BACKWARD_SECONDS,
|
||||||
|
type TapFeedback,
|
||||||
|
} from "./tapGestures";
|
||||||
import {
|
import {
|
||||||
setBackgroundAudioEnabled,
|
setBackgroundAudioEnabled,
|
||||||
subscribeAppBackgrounded,
|
subscribeAppBackgrounded,
|
||||||
@@ -101,11 +110,14 @@
|
|||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
let touchStartY = $state(0);
|
let touchStartY = $state(0);
|
||||||
let touchStartTime = $state(0);
|
let touchStartTime = $state(0);
|
||||||
let lastTapTime = $state(0);
|
let tapGestures = createTapGestureState();
|
||||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let brightness = $state(1); // 0-2, default 1
|
let brightness = $state(1); // 0-2, default 1
|
||||||
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Target of a skip already requested but not yet reported back by the player,
|
||||||
|
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||||
|
let pendingSeekTarget: number | null = null;
|
||||||
let swipeGestureActive = $state(false);
|
let swipeGestureActive = $state(false);
|
||||||
|
|
||||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||||
@@ -702,6 +714,16 @@
|
|||||||
if (debugLogInterval) {
|
if (debugLogInterval) {
|
||||||
clearInterval(debugLogInterval);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
|
// A deferred single tap must not fire play/pause after teardown.
|
||||||
|
if (tapTimeout) {
|
||||||
|
clearTimeout(tapTimeout);
|
||||||
|
tapTimeout = null;
|
||||||
|
}
|
||||||
|
tapGestures.cancel();
|
||||||
|
if (doubleTapFeedbackTimeout) {
|
||||||
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
|
doubleTapFeedbackTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||||
for (const unlisten of nativeUnlisteners) {
|
for (const unlisten of nativeUnlisteners) {
|
||||||
@@ -1191,9 +1213,13 @@
|
|||||||
|
|
||||||
function toggleBackgroundAudio() {
|
function toggleBackgroundAudio() {
|
||||||
backgroundAudioOn = !backgroundAudioOn;
|
backgroundAudioOn = !backgroundAudioOn;
|
||||||
|
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||||
// so exactly one background behavior is active.
|
// so exactly one background behavior is active.
|
||||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||||
|
if (!armed) {
|
||||||
|
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||||
|
}
|
||||||
setAutoEnterEnabled(!backgroundAudioOn);
|
setAutoEnterEnabled(!backgroundAudioOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,6 +1259,10 @@
|
|||||||
serverId: media.serverId ?? null,
|
serverId: media.serverId ?? null,
|
||||||
// Real duration so the lockscreen scrubber has a range to draw.
|
// Real duration so the lockscreen scrubber has a range to draw.
|
||||||
durationSeconds: duration > 0 ? duration : null,
|
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,
|
pos,
|
||||||
);
|
);
|
||||||
@@ -1337,7 +1367,16 @@
|
|||||||
async function seekRelative(seconds: number) {
|
async function seekRelative(seconds: number) {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
|
|
||||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
// The facade seeks by absolute position, so resolve the delta here —
|
||||||
|
// chaining off a still-in-flight target so rapid double taps accumulate
|
||||||
|
// instead of all resolving against the same not-yet-updated position.
|
||||||
|
const newTime = resolveSeekTarget({
|
||||||
|
delta: seconds,
|
||||||
|
reportedPosition: currentTime,
|
||||||
|
duration,
|
||||||
|
pendingTarget: pendingSeekTarget,
|
||||||
|
});
|
||||||
|
pendingSeekTarget = newTime;
|
||||||
|
|
||||||
console.log("[VideoPlayer] Relative seek:", {
|
console.log("[VideoPlayer] Relative seek:", {
|
||||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||||
@@ -1353,7 +1392,12 @@
|
|||||||
}
|
}
|
||||||
} as unknown as Event;
|
} as unknown as Event;
|
||||||
|
|
||||||
await handleSeekBarChange(syntheticEvent);
|
try {
|
||||||
|
await handleSeekBarChange(syntheticEvent);
|
||||||
|
} finally {
|
||||||
|
// The player is authoritative again from here on.
|
||||||
|
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -1370,10 +1414,10 @@
|
|||||||
}
|
}
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekRelative(-10);
|
seekRelative(SEEK_BACKWARD_SECONDS);
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekRelative(10);
|
seekRelative(SEEK_FORWARD_SECONDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1384,25 +1428,31 @@
|
|||||||
touchStartY = touch.clientY;
|
touchStartY = touch.clientY;
|
||||||
touchStartTime = Date.now();
|
touchStartTime = Date.now();
|
||||||
|
|
||||||
const now = Date.now();
|
const outcome = registerTap(tapGestures, {
|
||||||
const timeSinceLastTap = now - lastTapTime;
|
x: touch.clientX,
|
||||||
|
screenWidth: window.innerWidth,
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
// Double tap detection (within 300ms)
|
if (tapTimeout) {
|
||||||
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
clearTimeout(tapTimeout);
|
||||||
e.preventDefault();
|
tapTimeout = null;
|
||||||
handleDoubleTap(touch.clientX);
|
|
||||||
lastTapTime = 0; // Reset to prevent triple-tap
|
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastTapTime = now;
|
|
||||||
// Set timeout to clear if no second tap
|
|
||||||
tapTimeout = setTimeout(() => {
|
|
||||||
lastTapTime = 0;
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outcome.action === "seek") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tap so far: defer play/pause until the double-tap window closes,
|
||||||
|
// so a double tap seeks without also toggling pause.
|
||||||
|
tapTimeout = setTimeout(() => {
|
||||||
|
tapTimeout = null;
|
||||||
|
if (tapGestures.resolvePending(Date.now())) {
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
}, outcome.pendingAfterMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
@@ -1417,6 +1467,13 @@
|
|||||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||||
swipeGestureActive = true;
|
swipeGestureActive = true;
|
||||||
|
|
||||||
|
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||||
|
tapGestures.cancel();
|
||||||
|
if (tapTimeout) {
|
||||||
|
clearTimeout(tapTimeout);
|
||||||
|
tapTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Brightness control on vertical swipe
|
// Brightness control on vertical swipe
|
||||||
swipeType = "brightness";
|
swipeType = "brightness";
|
||||||
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
||||||
@@ -1433,19 +1490,21 @@
|
|||||||
swipeType = null;
|
swipeType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDoubleTap(x: number) {
|
/**
|
||||||
const screenWidth = window.innerWidth;
|
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||||
const isLeftSide = x < screenWidth / 2;
|
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
||||||
|
* so the compatibility click that follows a tap must be ignored here —
|
||||||
|
* otherwise it pauses on the first tap of a double tap.
|
||||||
|
*/
|
||||||
|
function handleVideoClick(e: MouseEvent) {
|
||||||
|
// A click synthesized from a touch reports no pointer movement detail.
|
||||||
|
if (e.detail === 0 || tapTimeout !== null) return;
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
|
||||||
if (isLeftSide) {
|
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
|
||||||
// Double tap left: rewind 10 seconds
|
seekRelative(seekSeconds);
|
||||||
seekRelative(-10);
|
showDoubleTapFeedback = feedback;
|
||||||
showDoubleTapFeedback = "left";
|
|
||||||
} else {
|
|
||||||
// Double tap right: forward 10 seconds
|
|
||||||
seekRelative(10);
|
|
||||||
showDoubleTapFeedback = "right";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide feedback after animation
|
// Hide feedback after animation
|
||||||
if (doubleTapFeedbackTimeout) {
|
if (doubleTapFeedbackTimeout) {
|
||||||
@@ -1591,7 +1650,7 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||||
class="max-w-full max-h-full"
|
class={videoFitClass()}
|
||||||
class:invisible={!isMediaReady}
|
class:invisible={!isMediaReady}
|
||||||
style="filter: brightness({brightness})"
|
style="filter: brightness({brightness})"
|
||||||
playsinline
|
playsinline
|
||||||
@@ -1607,7 +1666,7 @@
|
|||||||
onwaiting={handleWaiting}
|
onwaiting={handleWaiting}
|
||||||
onplaying={handlePlaying}
|
onplaying={handlePlaying}
|
||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={togglePlayPause}
|
onclick={handleVideoClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!-- Temporarily disabled to debug playback issues
|
||||||
{#each subtitleTracks() as track}
|
{#each subtitleTracks() as track}
|
||||||
@@ -1660,7 +1719,7 @@
|
|||||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1671,7 +1730,7 @@
|
|||||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DOUBLE_TAP_WINDOW_MS,
|
||||||
|
SEEK_FORWARD_SECONDS,
|
||||||
|
SEEK_BACKWARD_SECONDS,
|
||||||
|
createTapGestureState,
|
||||||
|
registerTap,
|
||||||
|
resolveSeekTarget,
|
||||||
|
} from "./tapGestures";
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = 1000;
|
||||||
|
const LEFT = 100;
|
||||||
|
const RIGHT = 900;
|
||||||
|
|
||||||
|
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
|
||||||
|
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
|
||||||
|
function asSeek(outcome: ReturnType<typeof tap>) {
|
||||||
|
if (outcome.action !== "seek") {
|
||||||
|
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tap gesture resolution", () => {
|
||||||
|
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
const first = tap(state, RIGHT, 1000);
|
||||||
|
|
||||||
|
// The first tap must NOT immediately toggle play/pause — it may still
|
||||||
|
// become a double tap.
|
||||||
|
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
|
||||||
|
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
||||||
|
expect(resolved).toEqual({ action: "togglePlayPause" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const second = asSeek(tap(state, RIGHT, 1150));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
|
expect(second.seekSeconds).toBe(30);
|
||||||
|
expect(second.feedback).toBe("right");
|
||||||
|
|
||||||
|
// The deferred single-tap pause must have been cancelled.
|
||||||
|
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks back 10s on a double tap on the left half", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, LEFT, 1000);
|
||||||
|
const second = asSeek(tap(state, LEFT, 1100));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||||
|
expect(second.seekSeconds).toBe(-10);
|
||||||
|
expect(second.feedback).toBe("left");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a second tap after the window as a new pending single tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||||
|
|
||||||
|
expect(late.action).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a third tap as another double tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||||
|
|
||||||
|
// Triple tap: the third tap starts a fresh pending tap rather than
|
||||||
|
// seeking again off the consumed second tap.
|
||||||
|
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates repeated double taps on the same side", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const a = asSeek(tap(state, RIGHT, 1100));
|
||||||
|
tap(state, RIGHT, 1200);
|
||||||
|
const b = asSeek(tap(state, RIGHT, 1300));
|
||||||
|
|
||||||
|
expect(a.seekSeconds).toBe(30);
|
||||||
|
expect(b.seekSeconds).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, LEFT, 1000);
|
||||||
|
const second = asSeek(tap(state, RIGHT, 1100));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
|
expect(second.feedback).toBe("right");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
state.cancel();
|
||||||
|
|
||||||
|
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seek target resolution", () => {
|
||||||
|
const DURATION = 600;
|
||||||
|
|
||||||
|
it("adds the delta to the reported position", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to zero when rewinding past the start", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to the duration when skipping past the end", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||||
|
// The player has not yet reported the first seek's result, so the
|
||||||
|
// reported position is still the pre-seek value.
|
||||||
|
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
|
||||||
|
const second = resolveSeekTarget({
|
||||||
|
delta: 30,
|
||||||
|
reportedPosition: 100,
|
||||||
|
duration: DURATION,
|
||||||
|
pendingTarget: first,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a pending target once the player has caught up past it", () => {
|
||||||
|
const target = resolveSeekTarget({
|
||||||
|
delta: 30,
|
||||||
|
reportedPosition: 200,
|
||||||
|
duration: DURATION,
|
||||||
|
pendingTarget: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toBe(230);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the delta alone when duration is unknown", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Tap-gesture interpretation for the video player surface.
|
||||||
|
*
|
||||||
|
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
||||||
|
* a tap cannot be classified at the moment it lands, because it may still turn
|
||||||
|
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
||||||
|
* until the double-tap window closes, and cancelled outright if a second tap
|
||||||
|
* arrives — otherwise a double tap both toggles pause and seeks.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A second tap within this window makes a double tap. */
|
||||||
|
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||||
|
|
||||||
|
/** Double tap on the right half: skip forward. */
|
||||||
|
export const SEEK_FORWARD_SECONDS = 30;
|
||||||
|
|
||||||
|
/** Double tap on the left half: skip back. */
|
||||||
|
export const SEEK_BACKWARD_SECONDS = -10;
|
||||||
|
|
||||||
|
export type TapFeedback = "left" | "right";
|
||||||
|
|
||||||
|
export type TapOutcome =
|
||||||
|
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
||||||
|
| { action: "pending"; pendingAfterMs: number }
|
||||||
|
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
||||||
|
|
||||||
|
export interface TapInput {
|
||||||
|
/** Tap x position, viewport pixels. */
|
||||||
|
x: number;
|
||||||
|
screenWidth: number;
|
||||||
|
now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TapGestureState {
|
||||||
|
/**
|
||||||
|
* Resolve a still-pending single tap. Returns the play/pause action once the
|
||||||
|
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
||||||
|
* became a double tap, or was cancelled).
|
||||||
|
*/
|
||||||
|
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
||||||
|
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalState extends TapGestureState {
|
||||||
|
lastTapTime: number;
|
||||||
|
pendingSince: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTapGestureState(): TapGestureState {
|
||||||
|
const state: InternalState = {
|
||||||
|
lastTapTime: 0,
|
||||||
|
pendingSince: null,
|
||||||
|
resolvePending(now: number) {
|
||||||
|
if (state.pendingSince === null) return null;
|
||||||
|
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
||||||
|
state.pendingSince = null;
|
||||||
|
return { action: "togglePlayPause" };
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.pendingSince = null;
|
||||||
|
state.lastTapTime = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
||||||
|
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
||||||
|
* the window returns the seek and clears the pending play/pause.
|
||||||
|
*/
|
||||||
|
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||||
|
const s = state as InternalState;
|
||||||
|
const sinceLastTap = input.now - s.lastTapTime;
|
||||||
|
|
||||||
|
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||||
|
// Second tap: cancel the deferred play/pause and seek instead.
|
||||||
|
s.pendingSince = null;
|
||||||
|
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
||||||
|
const isLeftSide = input.x < input.screenWidth / 2;
|
||||||
|
return isLeftSide
|
||||||
|
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
||||||
|
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastTapTime = input.now;
|
||||||
|
s.pendingSince = input.now;
|
||||||
|
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekTargetInput {
|
||||||
|
/** Relative offset in seconds (negative rewinds). */
|
||||||
|
delta: number;
|
||||||
|
/** Latest position reported by the player — the authoritative source. */
|
||||||
|
reportedPosition: number;
|
||||||
|
/** Media duration; 0/unknown disables the upper clamp. */
|
||||||
|
duration: number;
|
||||||
|
/**
|
||||||
|
* Target of a seek already requested but not yet reflected in
|
||||||
|
* `reportedPosition`. Consecutive double taps chain off this so they add up
|
||||||
|
* instead of all resolving against the same stale position.
|
||||||
|
*/
|
||||||
|
pendingTarget?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a relative skip to the absolute position the facade expects.
|
||||||
|
*
|
||||||
|
* The player facade seeks by absolute position only (the backend picks the seek
|
||||||
|
* strategy), so the delta is applied here — against the pending target when one
|
||||||
|
* is still in flight and still ahead of what the player has reported.
|
||||||
|
*/
|
||||||
|
export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||||
|
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||||
|
|
||||||
|
const base =
|
||||||
|
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||||
|
? pendingTarget
|
||||||
|
: reportedPosition;
|
||||||
|
|
||||||
|
const target = base + delta;
|
||||||
|
if (target < 0) return 0;
|
||||||
|
if (duration > 0 && target > duration) return duration;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
<MediaCard
|
||||||
{item}
|
{item}
|
||||||
size="medium"
|
size="medium"
|
||||||
showProgress={group.id !== "artists"}
|
showProgress={group.id !== "artists" && group.id !== "people"}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={() => onItemClick?.(item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// Home screen data store - featured items, continue watching, recently added
|
// 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 { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
import { filterSupersededResumeItems } from "./continueWatchingFilter";
|
||||||
|
|
||||||
interface HomeState {
|
interface HomeState {
|
||||||
heroItems: MediaItem[];
|
heroItems: MediaItem[];
|
||||||
@@ -50,8 +51,12 @@ function createHomeStore() {
|
|||||||
const valueOr = <T>(i: number, fallback: T): T =>
|
const valueOr = <T>(i: number, fallback: T): T =>
|
||||||
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
|
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);
|
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 latest = valueOr(2, [] as typeof initialState.latestItems);
|
||||||
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
||||||
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
||||||
|
|||||||
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
|
|||||||
it("loads a stored order", async () => {
|
it("loads a stored order", async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
|
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
||||||
);
|
);
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
"episodes",
|
||||||
|
"shows",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"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");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
|
|||||||
|
|
||||||
it("persists a move so the order survives a restart", async () => {
|
it("persists a move so the order survives a restart", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Default is shows, episodes, movies, songs, … — move movies up one.
|
||||||
searchGroupOrder.move("movies", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
|
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
const expected = [
|
||||||
|
"shows",
|
||||||
|
"movies",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
];
|
||||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
|
expect(get(searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Simulate a fresh app start reading the same storage.
|
// Simulate a fresh app start reading the same storage.
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const reloaded = await import("./searchGroupOrder");
|
const reloaded = await import("./searchGroupOrder");
|
||||||
expect(get(reloaded.searchGroupOrder)).toEqual([
|
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists a drag reorder", async () => {
|
it("persists a drag reorder", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Drag "albums" (index 4) to the front.
|
||||||
searchGroupOrder.reorder(4, 0);
|
searchGroupOrder.reorder(4, 0);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
|
||||||
"songs",
|
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets to the shipped default", async () => {
|
it("resets to the shipped default", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
searchGroupOrder.move("tvShows", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
searchGroupOrder.reset();
|
searchGroupOrder.reset();
|
||||||
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
||||||
});
|
});
|
||||||
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
|
|||||||
searchGroupOrder.set(["movies", "podcasts"] as never);
|
searchGroupOrder.set(["movies", "podcasts"] as never);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// TV library landing page data store.
|
// TV library landing page data store.
|
||||||
// Powers the focused TV landing: hero + horizontal sliders.
|
// 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 { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { buildHeroMix } from "$lib/utils/heroMix";
|
import { buildHeroMix } from "$lib/utils/heroMix";
|
||||||
|
import { filterSupersededResumeItems } from "./continueWatchingFilter";
|
||||||
|
|
||||||
/** A single "by genre" row: the genre name plus the series in it. */
|
/** A single "by genre" row: the genre name plus the series in it. */
|
||||||
export interface GenreRow {
|
export interface GenreRow {
|
||||||
@@ -81,7 +82,12 @@ function createTvStore() {
|
|||||||
|
|
||||||
// Resume items are already video-only from the server, but keep episodes
|
// Resume items are already video-only from the server, but keep episodes
|
||||||
// (and the occasional movie that lives in a mixed library) defensively.
|
// (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,
|
// Mix the hero: in-progress episodes first (most personal), then next-up,
|
||||||
// recent additions, and random series from across the library.
|
// recent additions, and random series from across the library.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { setBackgroundAudioEnabled } from "./backgroundAudio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge-reporting contract for the background-audio toggle.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040 | IR-025, DR-051 | UT-062
|
||||||
|
*
|
||||||
|
* Regression guard for the "screen lock kills video audio" bug: MainActivity
|
||||||
|
* re-ran configureWebViewForMedia() on every onResume, re-calling
|
||||||
|
* addJavascriptInterface over a live page. WebView then served a stale proxy —
|
||||||
|
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
|
||||||
|
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
|
||||||
|
*
|
||||||
|
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
|
||||||
|
* a try/catch returning void, so the UI showed "armed" while native never got
|
||||||
|
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
|
||||||
|
* `jellytau-background`. Audio died the instant the screen locked.
|
||||||
|
*
|
||||||
|
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
|
||||||
|
* reached, so a dead bridge can never masquerade as an armed toggle.
|
||||||
|
*/
|
||||||
|
describe("setBackgroundAudioEnabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports success when the bridge is present and the call lands", () => {
|
||||||
|
const setEnabled = vi.fn();
|
||||||
|
window.AndroidBackgroundAudio = { setEnabled };
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(true);
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge object is absent entirely", () => {
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure for a stale proxy whose methods are gone", () => {
|
||||||
|
// The exact shape of the bug: object present (so `?.` passes) but the
|
||||||
|
// method is missing after re-injection over a live page.
|
||||||
|
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge method throws", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new TypeError("e.setEnabled is not a function");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws out to the caller — the toggle must not break the player", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
interface AndroidBackgroundAudioBridge {
|
interface AndroidBackgroundAudioBridge {
|
||||||
setEnabled(enabled: boolean): void;
|
setEnabled(enabled: boolean): void;
|
||||||
isSupported(): boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
|||||||
return window.AndroidBackgroundAudio;
|
return window.AndroidBackgroundAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
|
||||||
export function isBackgroundAudioSupported(): boolean {
|
|
||||||
try {
|
|
||||||
return bridge()?.isSupported() ?? false;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[BgAudio] isSupported check failed:", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||||
* side runs the audio handoff on background instead of entering PiP.
|
* side runs the audio handoff on background instead of entering PiP.
|
||||||
*/
|
*/
|
||||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||||
|
const b = bridge();
|
||||||
|
if (!b) {
|
||||||
|
// The button is gated on platform(), not on this bridge, so it can render
|
||||||
|
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||||
|
// showing "armed" while native never learns — and the handoff then never
|
||||||
|
// fires on lock. Report it so callers can retry.
|
||||||
|
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
bridge()?.setEnabled(enabled);
|
b.setEnabled(enabled);
|
||||||
|
console.log("[BgAudio] setEnabled ->", enabled);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
normalizeGroupOrder,
|
normalizeGroupOrder,
|
||||||
reorderGroups,
|
reorderGroups,
|
||||||
resolveSearchScope,
|
resolveSearchScope,
|
||||||
|
searchRouteUrl,
|
||||||
|
shouldNavigateToSearch,
|
||||||
scopeItemTypes,
|
scopeItemTypes,
|
||||||
type SearchGroupId,
|
type SearchGroupId,
|
||||||
} from "./searchScope";
|
} from "./searchScope";
|
||||||
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
||||||
"songs",
|
"songs",
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a complete valid order unchanged", () => {
|
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);
|
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", () => {
|
describe("groupsForScope", () => {
|
||||||
it("returns every group in saved order for the all scope", () => {
|
it("returns every group in saved order for the all scope", () => {
|
||||||
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
|
expect(
|
||||||
"movies",
|
groupsForScope("all", [
|
||||||
"songs",
|
"movies",
|
||||||
"tvShows",
|
"songs",
|
||||||
"albums",
|
"shows",
|
||||||
"artists",
|
"episodes",
|
||||||
]);
|
"albums",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
|
])
|
||||||
|
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only in-scope groups, in saved order", () => {
|
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("music", order)).toEqual(["artists", "albums", "songs"]);
|
||||||
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
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", () => {
|
it("renders groups in the configured order", () => {
|
||||||
const groups = composeSearchGroups(results, "all", [
|
const groups = composeSearchGroups(results, "all", [
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"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", () => {
|
it("omits empty groups", () => {
|
||||||
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
|
|||||||
"albums",
|
"albums",
|
||||||
]);
|
]);
|
||||||
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
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);
|
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", () => {
|
it("ignores item types that belong to no group", () => {
|
||||||
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
|
||||||
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
|
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", () => {
|
it("narrowing then widening restores the full arrangement", () => {
|
||||||
// Scope is a filter over the saved order, never a rewrite of it.
|
// 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);
|
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
||||||
composeSearchGroups(results, "music", order);
|
composeSearchGroups(results, "music", order);
|
||||||
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
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", () => {
|
it("survives a stored order containing an unknown id", () => {
|
||||||
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
|
|||||||
"podcasts",
|
"podcasts",
|
||||||
"movies",
|
"movies",
|
||||||
] as unknown as SearchGroupId[]);
|
] 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", () => {
|
it("handles items with a missing type", () => {
|
||||||
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("moveGroup", () => {
|
describe("moveGroup", () => {
|
||||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||||
|
|
||||||
it("moves a group up", () => {
|
it("moves a group up", () => {
|
||||||
expect(moveGroup(order, "artists", -1)).toEqual([
|
expect(moveGroup(order, "artists", -1)).toEqual([
|
||||||
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
|
|||||||
"artists",
|
"artists",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
|
|||||||
"songs",
|
"songs",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op at the boundaries", () => {
|
it("is a no-op at the boundaries", () => {
|
||||||
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
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", () => {
|
it("is a no-op for an unknown id", () => {
|
||||||
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reorderGroups", () => {
|
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", () => {
|
it("moves an item from one index to another", () => {
|
||||||
expect(reorderGroups(order, 0, 4)).toEqual([
|
expect(reorderGroups(order, 0, 4)).toEqual([
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
]);
|
]);
|
||||||
expect(reorderGroups(order, 4, 0)).toEqual([
|
expect(reorderGroups(order, 4, 0)).toEqual([
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
|
|||||||
expect(reorderGroups(order, 0, 9)).toEqual(order);
|
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
@@ -62,51 +62,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
|||||||
return "all";
|
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
|
// 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[] = [
|
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"people",
|
||||||
"tvShows",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
||||||
|
shows: "TV Shows",
|
||||||
|
episodes: "Episodes",
|
||||||
|
movies: "Movies",
|
||||||
songs: "Songs",
|
songs: "Songs",
|
||||||
albums: "Albums",
|
albums: "Albums",
|
||||||
artists: "Artists",
|
artists: "Artists",
|
||||||
movies: "Movies",
|
people: "People",
|
||||||
tvShows: "TV Shows",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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",
|
songs: "music",
|
||||||
albums: "music",
|
albums: "music",
|
||||||
artists: "music",
|
artists: "music",
|
||||||
movies: "movies",
|
people: null,
|
||||||
tvShows: "tv",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Item types that fall into each group. */
|
/** Item types that fall into each group. */
|
||||||
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
||||||
|
shows: ["Series"],
|
||||||
|
episodes: ["Episode"],
|
||||||
|
movies: ["Movie"],
|
||||||
songs: ["Audio"],
|
songs: ["Audio"],
|
||||||
albums: ["MusicAlbum"],
|
albums: ["MusicAlbum"],
|
||||||
artists: ["MusicArtist"],
|
artists: ["MusicArtist"],
|
||||||
movies: ["Movie"],
|
people: ["Person"],
|
||||||
tvShows: ["Series", "Episode"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function groupItemTypes(group: SearchGroupId): string[] {
|
export function groupItemTypes(group: SearchGroupId): string[] {
|
||||||
return [...GROUP_ITEM_TYPES[group]];
|
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.
|
* Normalise a stored order into a usable one.
|
||||||
*
|
*
|
||||||
@@ -123,11 +208,15 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
|||||||
|
|
||||||
if (Array.isArray(stored)) {
|
if (Array.isArray(stored)) {
|
||||||
for (const id of stored) {
|
for (const id of stored) {
|
||||||
if (typeof id !== "string" || !known.has(id)) continue;
|
if (typeof id !== "string") continue;
|
||||||
const groupId = id as SearchGroupId;
|
// Retired ids expand in place rather than being dropped, so a user who
|
||||||
if (seen.has(groupId)) continue;
|
// dragged the old combined "TV Shows" group to the top keeps TV at the
|
||||||
seen.add(groupId);
|
// top instead of having shows/episodes appended to the bottom.
|
||||||
order.push(groupId);
|
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,
|
scope: SearchScope,
|
||||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||||
): SearchGroupId[] {
|
): 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(
|
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
||||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
import {
|
||||||
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
resolveSearchScope,
|
||||||
|
searchRouteUrl,
|
||||||
|
shouldNavigateToSearch,
|
||||||
|
type SearchScope,
|
||||||
|
} from "$lib/utils/searchScope";
|
||||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.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) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (!query.trim()) {
|
||||||
await library.search(query, searchScope);
|
|
||||||
} else {
|
|
||||||
library.clearSearch();
|
library.clearSearch();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (shouldNavigateToSearch($page.url.pathname, query)) {
|
||||||
|
await goto(searchRouteUrl(query, searchScope));
|
||||||
async function handleScopeChange(next: SearchScope) {
|
// The query now lives in the URL; clear the header input so returning to
|
||||||
searchScope = next;
|
// a library page does not leave a stale term sitting in the box.
|
||||||
if (searchQuery.trim()) {
|
searchQuery = "";
|
||||||
await library.search(searchQuery, next);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -74,14 +82,12 @@
|
|||||||
<AppHeader search={librarySearch} />
|
<AppHeader search={librarySearch} />
|
||||||
|
|
||||||
{#snippet librarySearch()}
|
{#snippet librarySearch()}
|
||||||
|
<!-- Scope chips live on /search, which owns the results. -->
|
||||||
<Search
|
<Search
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Search your library..."
|
placeholder="Search your library..."
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
/>
|
/>
|
||||||
{#if searchQuery.trim()}
|
|
||||||
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
||||||
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
||||||
|
|
||||||
let searchResults = $derived($library.searchResults);
|
// Search results are rendered exclusively by /search — this page used to
|
||||||
let searchQuery = $derived($library.searchQuery);
|
// 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");
|
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
|
||||||
|
|
||||||
@@ -169,28 +170,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
{#if searchQuery}
|
{#if showInlineLibraryContent}
|
||||||
<!-- 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}
|
|
||||||
<!-- Library content (live TV / channels / other inline-rendered types) -->
|
<!-- Library content (live TV / channels / other inline-rendered types) -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -147,6 +147,34 @@
|
|||||||
// Sort seasons by index number
|
// Sort seasons by index number
|
||||||
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
|
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,
|
// If we have a focused episode ID but couldn't find it in the seasons,
|
||||||
// fetch it directly (handles ID mismatch between APIs)
|
// fetch it directly (handles ID mismatch between APIs)
|
||||||
const episodeIdParam = $page.url.searchParams.get("episode");
|
const episodeIdParam = $page.url.searchParams.get("episode");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
reportPlaybackProgress,
|
reportPlaybackProgress,
|
||||||
reportPlaybackStopped,
|
reportPlaybackStopped,
|
||||||
} from "$lib/services/playbackReporting";
|
} from "$lib/services/playbackReporting";
|
||||||
|
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
||||||
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
|
|
||||||
@@ -536,7 +537,11 @@
|
|||||||
|
|
||||||
function handleReportStop(positionSeconds: number, reportId?: string) {
|
function handleReportStop(positionSeconds: number, reportId?: string) {
|
||||||
const id = reportId ?? itemId;
|
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);
|
reportPlaybackStopped(id, positionSeconds);
|
||||||
}
|
}
|
||||||
// Intentionally do NOT emit a "stopped" player state here. This runs on both
|
// Intentionally do NOT emit a "stopped" player state here. This runs on both
|
||||||
@@ -592,6 +597,14 @@
|
|||||||
|
|
||||||
function handleSkipToNextEpisode() {
|
function handleSkipToNextEpisode() {
|
||||||
if (nextEpisode) {
|
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.
|
// 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,
|
// restart=true so advancing to the next episode always starts from the beginning,
|
||||||
// even if it was previously started or watched.
|
// even if it was previously started or watched.
|
||||||
|
|||||||
@@ -5,15 +5,41 @@
|
|||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
||||||
import SearchScopeChips from "$lib/components/search/SearchScopeChips.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";
|
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
|
// 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.
|
// a user who widened to All back to the route's scope on any navigation.
|
||||||
// TRACES: UR-049 | DR-064
|
// 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) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user