Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a26a853f01 | ||
|
|
9d099268b9 |
@@ -249,6 +249,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 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 |
|
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 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 |
|
||||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||||
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
|
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
|
||||||
|
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
|
||||||
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
||||||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||||||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||||||
@@ -416,6 +417,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
||||||
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
||||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
||||||
|
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
|
||||||
|
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
|
||||||
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|||||||
+681
-465
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.2.7",
|
"version": "0.2.9",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(61);
|
expect(defined.UR).toBe(61);
|
||||||
expect(defined.IR).toBe(29);
|
expect(defined.IR).toBe(29);
|
||||||
expect(defined.DR).toBe(95);
|
expect(defined.DR).toBe(96);
|
||||||
expect(defined.JA).toBe(32);
|
expect(defined.JA).toBe(32);
|
||||||
expect(defined.total).toBe(217);
|
expect(defined.total).toBe(218);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.7"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.7"
|
version = "0.2.9"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
|||||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||||
|
|
||||||
/// Base offset (seconds) for the active background-audio handoff.
|
|
||||||
///
|
|
||||||
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
|
|
||||||
/// position, so the server makes that point the stream's zero. ExoPlayer then
|
|
||||||
/// reports position RELATIVE to that zero. To convert back to an absolute
|
|
||||||
/// position on exit (so the video resumes where the audio actually reached), we
|
|
||||||
/// add this stored base to the native player's reported position.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-040 | DR-052
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct BackgroundAudioOffset(pub Mutex<f64>);
|
|
||||||
|
|
||||||
/// Response for player state queries
|
/// Response for player state queries
|
||||||
#[derive(specta::Type, Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -586,7 +574,6 @@ pub async fn player_play_item(
|
|||||||
pub async fn player_enter_background_audio(
|
pub async fn player_enter_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
|
||||||
item: PlayItemRequest,
|
item: PlayItemRequest,
|
||||||
position_seconds: f64,
|
position_seconds: f64,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -636,17 +623,18 @@ pub async fn player_enter_background_audio(
|
|||||||
session_mgr.start_audio_session(media_item.clone());
|
session_mgr.start_audio_session(media_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember where the video was: the audio stream's zero == this position
|
|
||||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
|
||||||
// this base to the native player's relative position to get the absolute one.
|
|
||||||
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
|
|
||||||
|
|
||||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
// Remember where the video was: the audio stream's zero == this position
|
||||||
|
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||||
|
// this base to the native player's relative position to get the absolute one.
|
||||||
|
// The controller owns it so a backend-driven advance to the next episode
|
||||||
|
// clears it along with the stream it described.
|
||||||
|
controller.set_background_audio_base(position_seconds);
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -677,21 +665,15 @@ pub async fn player_enter_background_audio(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
|
||||||
) -> Result<f64, String> {
|
) -> Result<f64, String> {
|
||||||
// The base offset (handoff position) + native player's relative position =
|
|
||||||
// the absolute position to resume the video at. Read/reset the base first.
|
|
||||||
let base = {
|
|
||||||
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
let b = *off;
|
|
||||||
*off = 0.0;
|
|
||||||
b
|
|
||||||
};
|
|
||||||
|
|
||||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
// The base offset (handoff position) + native player's relative position =
|
||||||
|
// the absolute position to resume the video at. Zero after a backend-driven
|
||||||
|
// episode advance, whose stream already starts at its own zero.
|
||||||
|
let base = controller.take_background_audio_base();
|
||||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||||
let relative = controller.position();
|
let relative = controller.position();
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
|
|||||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
/// - Android JNI callback also triggers this logic directly
|
/// - Android JNI callback also triggers this logic directly
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_on_playback_ended(
|
pub async fn player_on_playback_ended(
|
||||||
@@ -242,12 +244,17 @@ pub async fn player_on_playback_ended(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start countdown if auto_advance enabled
|
// Advance if auto_advance is enabled. This is the path that actually
|
||||||
|
// runs on Android: the JNI callback's own decision is swallowed by the
|
||||||
|
// NewTrackLoaded end reason set at load, so it returns Stop, emits
|
||||||
|
// PlaybackEnded, and the frontend echoes it back into this command —
|
||||||
|
// which is where the real decision lands.
|
||||||
if auto_advance {
|
if auto_advance {
|
||||||
controller_arc
|
controller_arc
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1196,9 +1196,6 @@ pub fn run() {
|
|||||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||||
app.manage(video_settings);
|
app.manage(video_settings);
|
||||||
|
|
||||||
// Background-audio handoff base offset (UR-040).
|
|
||||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
|
||||||
|
|
||||||
// Initialize thumbnail cache
|
// Initialize thumbnail cache
|
||||||
info!("[INIT] Initializing thumbnail cache...");
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
|||||||
@@ -915,39 +915,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
}
|
}
|
||||||
|
|
||||||
if auto_advance {
|
if auto_advance {
|
||||||
// Background audio-only episode: the frontend that normally
|
// Shared with the frontend-invoked command path
|
||||||
// performs the advance (goto /player/<id>) is suspended, so
|
// (player_on_playback_ended) so the two dispatchers cannot
|
||||||
// the backend must load the next episode's audio-only stream
|
// disagree about how a background audio-only episode
|
||||||
// itself — otherwise playback just stops at the boundary.
|
// advances — they did, and the command's copy was missing
|
||||||
let is_bg_audio_episode =
|
// the case entirely. That copy is the one that actually
|
||||||
controller.lock().await.current_is_audio_episode();
|
// decides here: the end reason set at load makes this
|
||||||
if is_bg_audio_episode {
|
// callback's own decision Stop, and the frontend echoes the
|
||||||
log::info!(
|
// resulting PlaybackEnded back into the command.
|
||||||
"[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
|
controller
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+202
-4
@@ -105,7 +105,7 @@ pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String
|
|||||||
}
|
}
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
@@ -153,6 +153,21 @@ pub struct PlayerController {
|
|||||||
// Auto-play episode counter (session-based, resets on manual play)
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||||
|
|
||||||
|
// Base offset (seconds) of the active background-audio handoff.
|
||||||
|
//
|
||||||
|
// The audio-only stream is requested with `StartTimeTicks` = the position the
|
||||||
|
// video was handed off at, so the server makes that point the stream's zero
|
||||||
|
// and the native player reports position RELATIVE to it. Adding this base back
|
||||||
|
// yields the absolute position to resume the video at on the way out.
|
||||||
|
//
|
||||||
|
// Lives on the controller (not beside the command) because the queue and this
|
||||||
|
// offset describe the same stream: whenever the controller loads a different
|
||||||
|
// one — notably the backend-driven advance to the next episode — the base has
|
||||||
|
// to move with it.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-052
|
||||||
|
background_audio_base: Arc<Mutex<f64>>,
|
||||||
|
|
||||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||||
//
|
//
|
||||||
// Webview-rendered media is played by an element the native backend cannot
|
// Webview-rendered media is played by an element the native backend cannot
|
||||||
@@ -185,6 +200,7 @@ impl PlayerController {
|
|||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
|
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||||
html5_playing: Arc::new(Mutex::new(None)),
|
html5_playing: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1170,6 +1186,68 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record the base offset of a background-audio handoff (the position the
|
||||||
|
/// video was handed off at, which is the audio stream's zero).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
pub fn set_background_audio_base(&self, seconds: f64) {
|
||||||
|
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and clear the background-audio base offset.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
pub fn take_background_audio_base(&self) -> f64 {
|
||||||
|
let mut base = self.background_audio_base.lock_safe();
|
||||||
|
std::mem::replace(&mut *base, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
|
||||||
|
///
|
||||||
|
/// Single place both end-of-playback dispatchers agree on: the Android JNI
|
||||||
|
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
|
||||||
|
/// (`player_on_playback_ended`). They used to each carry their own copy of
|
||||||
|
/// this branch, and the command's copy was missing the background-audio case
|
||||||
|
/// entirely — so an audio-only episode ending while backgrounded only ever
|
||||||
|
/// started a countdown that nothing could act on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-052
|
||||||
|
pub async fn auto_advance_to_next_episode(
|
||||||
|
&self,
|
||||||
|
next_episode: crate::repository::types::MediaItem,
|
||||||
|
countdown_seconds: u32,
|
||||||
|
) {
|
||||||
|
// Background audio-only episode: the countdown only emits ticks — the
|
||||||
|
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
|
||||||
|
// start audio while the app is backgrounded. Load the next episode's
|
||||||
|
// audio-only stream here instead, or playback stalls at the boundary.
|
||||||
|
if self.current_is_audio_episode() {
|
||||||
|
info!(
|
||||||
|
"[PlayerController] Background audio episode — advancing to {} in backend",
|
||||||
|
next_episode.id
|
||||||
|
);
|
||||||
|
match self
|
||||||
|
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => self.emit_queue_changed(),
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"[PlayerController] Background audio advance failed: {} — stopping",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if let Some(emitter) = self.event_emitter() {
|
||||||
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground: the frontend drives the advance off the countdown ticks.
|
||||||
|
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||||
|
}
|
||||||
|
|
||||||
/// Advance to the next episode while playing audio-only in the background.
|
/// Advance to the next episode while playing audio-only in the background.
|
||||||
///
|
///
|
||||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||||
@@ -1180,10 +1258,10 @@ impl PlayerController {
|
|||||||
///
|
///
|
||||||
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
||||||
///
|
///
|
||||||
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
|
/// Reached through `auto_advance_to_next_episode`, which gates it on
|
||||||
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
|
/// `current_is_audio_episode()` — only ever true after a background-audio
|
||||||
|
/// handoff (Android), but compiled and unit-tested on every platform.
|
||||||
/// TRACES: UR-040, UR-023 | DR-052
|
/// 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(
|
pub async fn advance_to_next_episode_audio_only(
|
||||||
&self,
|
&self,
|
||||||
next_episode_id: &str,
|
next_episode_id: &str,
|
||||||
@@ -1238,6 +1316,13 @@ impl PlayerController {
|
|||||||
server_id: Some(next.server_id.clone()),
|
server_id: Some(next.server_id.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The previous episode's handoff base described the stream we are leaving.
|
||||||
|
// This one is built without StartTimeTicks, so its timeline is already
|
||||||
|
// absolute: clear the base (used to resolve the resume position on the way
|
||||||
|
// back to the foreground) and the lockscreen scrubber's matching shift.
|
||||||
|
self.set_background_audio_base(0.0);
|
||||||
|
let _ = set_lockscreen_position_offset(0.0);
|
||||||
|
|
||||||
self.play_item(media_item).map_err(|e| e.to_string())
|
self.play_item(media_item).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2994,6 +3079,119 @@ mod tests {
|
|||||||
assert!(controller.current_is_audio_episode());
|
assert!(controller.current_is_audio_episode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The handoff base offset describes ONE stream: the audio-only URL built
|
||||||
|
/// with `StartTimeTicks` = the position the video was handed off at, whose
|
||||||
|
/// timeline therefore starts at that point. The next episode is loaded from
|
||||||
|
/// its own beginning, so its timeline is already absolute and the base must
|
||||||
|
/// be cleared — otherwise returning to the foreground resolves the resume
|
||||||
|
/// position as `old_base + position_in_new_episode` and the video jumps to a
|
||||||
|
/// point that has nothing to do with what was playing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
// Handed off 20 minutes into the previous episode.
|
||||||
|
controller.set_background_audio_base(1200.0);
|
||||||
|
|
||||||
|
controller
|
||||||
|
.advance_to_next_episode_audio_only("ep2")
|
||||||
|
.await
|
||||||
|
.expect("advance should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
controller.take_background_audio_base(),
|
||||||
|
0.0,
|
||||||
|
"the next episode starts at its own zero, so the previous handoff \
|
||||||
|
base must not survive the advance"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||||
|
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||||
|
/// countdown the frontend is supposed to act on.
|
||||||
|
///
|
||||||
|
/// The countdown only emits CountdownTick events; the actual advance is a
|
||||||
|
/// `goto('/player/<id>')` in the webview. While the app is backgrounded that
|
||||||
|
/// navigation cannot start audio, so playback stalls at the episode boundary
|
||||||
|
/// with ExoPlayer parked in STATE_ENDED — and any later play intent
|
||||||
|
/// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
|
||||||
|
/// start, which is what surfaces to the user as "the episode randomly
|
||||||
|
/// restarted".
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_advance_background_audio_episode_advances_in_backend() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
// Currently playing: ep2 handed off to audio-only background playback.
|
||||||
|
let episode = MediaItem {
|
||||||
|
id: "ep2".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep2-audio.mp3".to_string(),
|
||||||
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
|
||||||
|
let next = make_repo_episode("ep3", 3);
|
||||||
|
controller.auto_advance_to_next_episode(next, 10).await;
|
||||||
|
|
||||||
|
let current = controller
|
||||||
|
.queue
|
||||||
|
.lock_safe()
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.expect("an item should still be loaded");
|
||||||
|
assert_eq!(
|
||||||
|
current.id, "ep3",
|
||||||
|
"background audio-only episode must advance in the backend, not wait \
|
||||||
|
for a frontend navigation that cannot happen while backgrounded"
|
||||||
|
);
|
||||||
|
assert_eq!(current.media_type, MediaType::Audio);
|
||||||
|
assert!(controller.current_is_audio_episode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
||||||
|
/// owns the navigation there, so the backend must NOT load the next episode
|
||||||
|
/// itself (that would race the page transition and double-start playback).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_advance_foreground_video_episode_uses_countdown() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
id: "ep2".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep2.m3u8".to_string(),
|
||||||
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
|
||||||
|
let next = make_repo_episode("ep3", 3);
|
||||||
|
controller.auto_advance_to_next_episode(next, 10).await;
|
||||||
|
|
||||||
|
let current = controller
|
||||||
|
.queue
|
||||||
|
.lock_safe()
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.expect("an item should still be loaded");
|
||||||
|
assert_eq!(
|
||||||
|
current.id, "ep2",
|
||||||
|
"foreground video advance is frontend-driven; the backend must not \
|
||||||
|
swap the queue item out from under it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.2.7",
|
"version": "0.2.9",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
|||||||
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||||
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
* - Android JNI callback also triggers this logic directly
|
* - Android JNI callback also triggers this logic directly
|
||||||
|
*
|
||||||
|
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||||
*/
|
*/
|
||||||
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- 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 -->
|
<!-- 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, DR-098, DR-099 -->
|
||||||
<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";
|
||||||
@@ -124,6 +124,14 @@
|
|||||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||||
let pendingSeekTarget: number | null = null;
|
let pendingSeekTarget: number | null = null;
|
||||||
let swipeGestureActive = $state(false);
|
let swipeGestureActive = $state(false);
|
||||||
|
// Whether the in-flight touch belongs to the player surface (and so may be
|
||||||
|
// read as a tap/swipe gesture) rather than to a control. Set on touchstart,
|
||||||
|
// cleared on touchend — see handleTouchMove for why a per-gesture flag and not
|
||||||
|
// just a per-event target check.
|
||||||
|
let playerGestureActive = false;
|
||||||
|
// Raised when the user changes the seek bar's value, cleared by whichever
|
||||||
|
// release signal commits the seek. See handleSeekBarRelease.
|
||||||
|
let seekCommitArmed = 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)
|
||||||
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
|
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
|
||||||
@@ -1162,14 +1170,33 @@
|
|||||||
const targetTime = parseFloat(input.value);
|
const targetTime = parseFloat(input.value);
|
||||||
// Update the displayed time immediately for smooth visual feedback
|
// Update the displayed time immediately for smooth visual feedback
|
||||||
currentTime = targetTime;
|
currentTime = targetTime;
|
||||||
|
// The user has moved the value; the next release must commit it.
|
||||||
|
seekCommitArmed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSeekBarChange(e: Event) {
|
/**
|
||||||
const input = e.target as HTMLInputElement;
|
* Seek-bar released — commit the value the user landed on, at most once.
|
||||||
|
*
|
||||||
|
* Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
|
||||||
|
* dependable: Android's WebView does not reliably fire it for a touch
|
||||||
|
* interaction on a range input, so the thumb moved to the tapped position but
|
||||||
|
* the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
|
||||||
|
* `change` deliver both signals, hence the arm/disarm — whichever arrives
|
||||||
|
* first commits and the other is a no-op.
|
||||||
|
*/
|
||||||
|
function handleSeekBarRelease(e: Event) {
|
||||||
|
isDraggingSeekBar = false;
|
||||||
|
if (!seekCommitArmed) return;
|
||||||
|
seekCommitArmed = false;
|
||||||
|
const input = (e.currentTarget ?? e.target) as HTMLInputElement;
|
||||||
|
void commitSeek(parseFloat(input.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSeek(rawTarget: number) {
|
||||||
// Clamp strictly inside the media: the range input's max IS the duration, so
|
// Clamp strictly inside the media: the range input's max IS the duration, so
|
||||||
// dragging fully right would otherwise request a segment past the media end,
|
// dragging fully right would otherwise request a segment past the media end,
|
||||||
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
|
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
|
||||||
const targetTime = clampSeekTarget(parseFloat(input.value), duration);
|
const targetTime = clampSeekTarget(rawTarget, duration);
|
||||||
|
|
||||||
// Set isSeeking immediately to prevent timeupdate from interfering
|
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
@@ -1406,16 +1433,9 @@
|
|||||||
to: newTime.toFixed(2),
|
to: newTime.toFixed(2),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call the unified handleSeekBarChange logic with the new time
|
// Same commit path as the seek bar — one place decides how a seek is issued.
|
||||||
// Create a synthetic event to reuse the existing logic
|
|
||||||
const syntheticEvent = {
|
|
||||||
target: {
|
|
||||||
value: newTime.toString()
|
|
||||||
}
|
|
||||||
} as unknown as Event;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleSeekBarChange(syntheticEvent);
|
await commitSeek(newTime);
|
||||||
} finally {
|
} finally {
|
||||||
// The player is authoritative again from here on.
|
// The player is authoritative again from here on.
|
||||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||||
@@ -1473,7 +1493,16 @@
|
|||||||
// container and touch events bubble, so without this a tap on the bottom
|
// container and touch events bubble, so without this a tap on the bottom
|
||||||
// play button would toggle here AND again via the button's own click — the
|
// play button would toggle here AND again via the button's own click — the
|
||||||
// two cancelling out and leaving the control apparently dead (DR-098).
|
// two cancelling out and leaving the control apparently dead (DR-098).
|
||||||
if (isControlSurfaceTouch(ancestorChain(e.target))) return;
|
if (isControlSurfaceTouch(ancestorChain(e.target))) {
|
||||||
|
// The move handler must stay out of it too. It reads touchStartX/Y, which
|
||||||
|
// this early return leaves at the PREVIOUS gesture's values, so a seek-bar
|
||||||
|
// drag came out as a huge vertical delta: it was mis-read as a brightness
|
||||||
|
// swipe, which dimmed the screen and fired a spurious play/pause
|
||||||
|
// "correction" mid-drag (DR-098).
|
||||||
|
playerGestureActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playerGestureActive = true;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
touchStartX = touch.clientX;
|
touchStartX = touch.clientX;
|
||||||
@@ -1505,6 +1534,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
// Only a gesture that began on the bare video surface is ours. Re-checking
|
||||||
|
// the target here would not be enough: the touch that started on a control
|
||||||
|
// never recorded a start point, so any delta computed here is meaningless.
|
||||||
|
if (!playerGestureActive) return;
|
||||||
if (!e.touches[0]) return;
|
if (!e.touches[0]) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
@@ -1537,6 +1570,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd(e: TouchEvent) {
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
playerGestureActive = false;
|
||||||
swipeGestureActive = false;
|
swipeGestureActive = false;
|
||||||
swipeType = null;
|
swipeType = null;
|
||||||
}
|
}
|
||||||
@@ -1900,11 +1934,11 @@
|
|||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
oninput={handleSeekBarInput}
|
oninput={handleSeekBarInput}
|
||||||
onchange={handleSeekBarChange}
|
onchange={handleSeekBarRelease}
|
||||||
onmousedown={() => isDraggingSeekBar = true}
|
onmousedown={() => isDraggingSeekBar = true}
|
||||||
onmouseup={() => isDraggingSeekBar = false}
|
onmouseup={handleSeekBarRelease}
|
||||||
ontouchstart={() => isDraggingSeekBar = true}
|
ontouchstart={() => isDraggingSeekBar = true}
|
||||||
ontouchend={() => isDraggingSeekBar = false}
|
ontouchend={handleSeekBarRelease}
|
||||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* VideoPlayer seek-bar TOUCH scrub regression tests (Android).
|
||||||
|
*
|
||||||
|
* Reported bug: on Android, dragging the progress bar does not change the
|
||||||
|
* playback location.
|
||||||
|
*
|
||||||
|
* The gesture listener lives on the outer container and touch events bubble.
|
||||||
|
* `handleTouchStart` ignores touches that land on a control (the seek bar is an
|
||||||
|
* <input>, inside `data-player-controls`) — but `handleTouchMove` does not, so a
|
||||||
|
* seek-bar drag is still interpreted as a container swipe. That mis-read swipe
|
||||||
|
* fires `togglePlayPause()` (undoing a first-tap toggle that never happened) and
|
||||||
|
* hijacks the drag into brightness control.
|
||||||
|
*
|
||||||
|
* The existing scrub regression tests only drive the slider with MOUSE events,
|
||||||
|
* which never reach the touch handlers — which is why this survived.
|
||||||
|
*
|
||||||
|
* The seek was also committed only from `change`, which Android's WebView does
|
||||||
|
* not reliably fire for a touch interaction on a range input — so a tap moved
|
||||||
|
* the thumb and no seek ever ran. Release now commits from touchend/mouseup too.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-061 | DR-098, DR-099 | UT-089, UT-090
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async (channel: string, handler: any) => {
|
||||||
|
channelHandlers[channel] = handler;
|
||||||
|
return () => {
|
||||||
|
delete channelHandlers[channel];
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const playerPlayItem = vi.fn(async () => ({
|
||||||
|
useHtml5Element: false,
|
||||||
|
backend: "exoplayer",
|
||||||
|
state: { kind: "playing" },
|
||||||
|
}));
|
||||||
|
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
||||||
|
strategy: "native",
|
||||||
|
position,
|
||||||
|
}));
|
||||||
|
const playerStop = vi.fn(async () => ({}));
|
||||||
|
const playerToggle = vi.fn(async () => ({ state: "playing" }));
|
||||||
|
|
||||||
|
vi.mock("$lib/api/bindings", () => ({
|
||||||
|
commands: {
|
||||||
|
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
||||||
|
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
|
||||||
|
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
||||||
|
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
|
||||||
|
playerPlay: vi.fn(async () => ({})),
|
||||||
|
playerPause: vi.fn(async () => ({})),
|
||||||
|
playerSetSleepTimer: vi.fn(async () => ({})),
|
||||||
|
playerCancelSleepTimer: vi.fn(async () => ({})),
|
||||||
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||||
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||||
|
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||||
|
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: {
|
||||||
|
getUserId: () => "user-1",
|
||||||
|
getRepository: () => ({
|
||||||
|
getHandle: () => "repo-1",
|
||||||
|
getSubtitleUrl: async () => "",
|
||||||
|
jrayActorsAt: async () => [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({
|
||||||
|
goto: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
|
function makeEpisode(): MediaItem {
|
||||||
|
return {
|
||||||
|
id: "ep1",
|
||||||
|
name: "Episode 1",
|
||||||
|
kind: "episode",
|
||||||
|
durationMs: 24 * 60 * 1000, // 24 min
|
||||||
|
} as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountAndroidPlayer() {
|
||||||
|
const utils = render(VideoPlayer, {
|
||||||
|
props: {
|
||||||
|
media: makeEpisode(),
|
||||||
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
|
mediaSourceId: "src-1",
|
||||||
|
needsTranscoding: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const slider = utils.container.querySelector(
|
||||||
|
'input[type="range"]'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(slider).not.toBeNull();
|
||||||
|
return { ...utils, slider, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(x: number, y: number) {
|
||||||
|
return { clientX: x, clientY: y } as Touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag the seek bar with TOUCH events, the way a finger does on Android.
|
||||||
|
*
|
||||||
|
* A real drag along the bar moves the finger far enough that the container's
|
||||||
|
* swipe detector (50px) would trigger if it were still listening.
|
||||||
|
*/
|
||||||
|
async function touchScrubTo(
|
||||||
|
slider: HTMLInputElement,
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
target: number
|
||||||
|
) {
|
||||||
|
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||||
|
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||||
|
// drag; the horizontal travel is what matters.
|
||||||
|
await fireEvent.touchMove(slider, { touches: [touch(400, 690)] });
|
||||||
|
slider.value = String(target);
|
||||||
|
await fireEvent.input(slider);
|
||||||
|
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
|
||||||
|
await fireEvent.change(slider);
|
||||||
|
await fireEvent.touchEnd(slider, { touches: [] });
|
||||||
|
if (video) await fireEvent(video, new Event("seeked"));
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a touch drag on the seek bar seeks to the dragged position", async () => {
|
||||||
|
const { slider, video } = await mountAndroidPlayer();
|
||||||
|
|
||||||
|
await touchScrubTo(slider, video, 600);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||||
|
);
|
||||||
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
||||||
|
const { slider, video } = await mountAndroidPlayer();
|
||||||
|
|
||||||
|
await touchScrubTo(slider, video, 600);
|
||||||
|
|
||||||
|
// The container gesture layer must stay out of a control drag entirely:
|
||||||
|
// no swipe mis-read, so no play/pause correction.
|
||||||
|
expect(playerToggle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits the seek on touchend even when the engine never fires `change`", async () => {
|
||||||
|
const { slider, video } = await mountAndroidPlayer();
|
||||||
|
|
||||||
|
// Android's WebView does not reliably fire `change` for a touch interaction
|
||||||
|
// on a range input. A tap on the track still moves the thumb and fires
|
||||||
|
// `input` — the seek must be committed on release regardless.
|
||||||
|
await fireEvent.touchStart(slider, { touches: [touch(400, 700)] });
|
||||||
|
slider.value = "600";
|
||||||
|
await fireEvent.input(slider);
|
||||||
|
await fireEvent.touchEnd(slider, { touches: [] });
|
||||||
|
if (video) await fireEvent(video, new Event("seeked"));
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits the seek exactly once when both touchend and change fire", async () => {
|
||||||
|
const { slider, video } = await mountAndroidPlayer();
|
||||||
|
|
||||||
|
await touchScrubTo(slider, video, 600);
|
||||||
|
|
||||||
|
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
||||||
|
const { slider, video, container } = await mountAndroidPlayer();
|
||||||
|
|
||||||
|
await touchScrubTo(slider, video, 600);
|
||||||
|
|
||||||
|
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
||||||
|
// leave it untouched.
|
||||||
|
const el = container.querySelector("video") as HTMLVideoElement | null;
|
||||||
|
if (el) {
|
||||||
|
expect(el.style.filter).toBe("brightness(1)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user