Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 |
+11
-6
@@ -71,7 +71,7 @@ For a narrative overview of the system design, see
|
||||
| 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 |
|
||||
| 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. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -246,8 +246,12 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
| 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-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-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-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-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
@@ -408,10 +412,11 @@ Internal architecture, components, and application logic.
|
||||
| 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-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 |
|
||||
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | 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-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-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
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -20,6 +20,8 @@
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:device": "./scripts/build-android.sh --device",
|
||||
"android:build:release:device": "./scripts/build-android.sh release --device",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
"android:deploy": "./scripts/deploy-android.sh",
|
||||
"android:dev": "./scripts/build-and-deploy.sh",
|
||||
|
||||
@@ -18,15 +18,50 @@ echo ""
|
||||
# Parse args: build type (debug/release) and optional --clean flag.
|
||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||
#
|
||||
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
|
||||
# which is what a distributable universal APK needs — but for an on-device test
|
||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
ABI="${ABI:-}"
|
||||
next_is_abi=0
|
||||
for arg in "$@"; do
|
||||
if [ "$next_is_abi" = "1" ]; then
|
||||
ABI="$arg"
|
||||
next_is_abi=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
--abi) next_is_abi=1 ;;
|
||||
--device) ABI="device" ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve --device to the attached device's Rust target triple.
|
||||
if [ "$ABI" = "device" ]; then
|
||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||
case "$device_abi" in
|
||||
arm64-v8a) ABI="aarch64" ;;
|
||||
armeabi-v7a) ABI="armv7" ;;
|
||||
x86_64) ABI="x86_64" ;;
|
||||
x86) ABI="i686" ;;
|
||||
*)
|
||||
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
|
||||
ABI=""
|
||||
;;
|
||||
esac
|
||||
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
|
||||
fi
|
||||
|
||||
TARGET_ARGS=()
|
||||
if [ -n "$ABI" ]; then
|
||||
TARGET_ARGS=(--target "$ABI")
|
||||
fi
|
||||
|
||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk true
|
||||
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
else
|
||||
echo "📦 Building debug APK..."
|
||||
bun run tauri android build --apk true --debug
|
||||
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(91);
|
||||
expect(defined.DR).toBe(95);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(213);
|
||||
expect(defined.total).toBe(217);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.2.1"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.1"
|
||||
version = "0.2.7"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
+231
-1
@@ -152,6 +152,17 @@ pub struct PlayerController {
|
||||
|
||||
// Auto-play episode counter (session-based, resets on manual play)
|
||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||
|
||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||
//
|
||||
// Webview-rendered media is played by an element the native backend cannot
|
||||
// reach, so the backend's own state() says nothing about it. Tracking the
|
||||
// REPORTED state here is what lets transport (play/pause/toggle) be decided
|
||||
// in Rust for that media instead of the frontend reading `el.paused` off the
|
||||
// DOM — a value that flips transiently while buffering/seeking and caused
|
||||
// competing intents to take opposing actions. `None` means no webview media
|
||||
// is active and the native backend is authoritative. See DR-097.
|
||||
html5_playing: Arc<Mutex<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl PlayerController {
|
||||
@@ -174,6 +185,7 @@ impl PlayerController {
|
||||
position_throttler,
|
||||
end_reason: Arc::new(Mutex::new(None)),
|
||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||
html5_playing: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
// Start background timer thread for sleep timer countdown
|
||||
@@ -476,21 +488,72 @@ impl PlayerController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
|
||||
/// player, so transport must be routed to it rather than the native backend.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-097
|
||||
pub fn is_html5_active(&self) -> bool {
|
||||
self.html5_playing.lock_safe().is_some()
|
||||
}
|
||||
|
||||
/// Whether the webview element last reported itself as playing. Meaningless
|
||||
/// unless [`Self::is_html5_active`] is true.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-097
|
||||
pub fn html5_is_playing(&self) -> bool {
|
||||
self.html5_playing.lock_safe().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Send a transport intent to the webview element that is rendering media.
|
||||
fn emit_html5_control(&self, action: &str) {
|
||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||
emitter.emit(PlayerStatusEvent::ControlCommand {
|
||||
action: action.to_string(),
|
||||
position: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Play/resume playback
|
||||
pub fn play(&self) -> Result<(), PlayerError> {
|
||||
debug!("[PlayerController] play");
|
||||
// Webview-rendered media: the native backend isn't playing it, so drive
|
||||
// the element via a ControlCommand instead (DR-097).
|
||||
if self.is_html5_active() {
|
||||
self.emit_html5_control("play");
|
||||
return Ok(());
|
||||
}
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.play()
|
||||
}
|
||||
|
||||
/// Pause playback
|
||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
||||
if self.is_html5_active() {
|
||||
self.emit_html5_control("pause");
|
||||
return Ok(());
|
||||
}
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.pause()
|
||||
}
|
||||
|
||||
/// Toggle play/pause
|
||||
/// Toggle play/pause.
|
||||
///
|
||||
/// The decision is made HERE, from authoritative state — the reported webview
|
||||
/// state for HTML5-rendered media, or the native backend's state otherwise.
|
||||
/// The frontend must never decide this from the DOM (see DR-097).
|
||||
///
|
||||
/// TRACES: UR-005 | DR-097
|
||||
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
||||
if self.is_html5_active() {
|
||||
let action = if self.html5_is_playing() {
|
||||
"pause"
|
||||
} else {
|
||||
"play"
|
||||
};
|
||||
self.emit_html5_control(action);
|
||||
return Ok(());
|
||||
}
|
||||
let mut backend = self.backend.lock_safe();
|
||||
if backend.state().is_playing() {
|
||||
backend.pause()
|
||||
@@ -890,6 +953,23 @@ impl PlayerController {
|
||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
||||
// Track it: this is the authoritative play/pause state for
|
||||
// webview-rendered media, and what transport decisions read (DR-097).
|
||||
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
||||
// the native backend — otherwise music playback would keep emitting
|
||||
// ControlCommands at a element that no longer exists.
|
||||
{
|
||||
let mut tracked = self.html5_playing.lock_safe();
|
||||
*tracked = match state.as_str() {
|
||||
"playing" => Some(true),
|
||||
// "loading" counts as active-but-not-playing so a toggle during
|
||||
// load resolves to "play" rather than falling through to the
|
||||
// native backend.
|
||||
"paused" | "loading" => Some(false),
|
||||
// "stopped"/"idle": element is gone, native backend resumes authority.
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
||||
}
|
||||
@@ -1489,6 +1569,156 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTML5 transport authority (DR-097) =====
|
||||
//
|
||||
// Webview-rendered video is played by an element the native backend cannot
|
||||
// reach, so transport for it must be decided from the state the element
|
||||
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
|
||||
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
|
||||
// flips transiently while buffering/seeking — two intents ~150ms apart read
|
||||
// different values, took opposing actions, and self-sustained a pause loop.
|
||||
|
||||
#[test]
|
||||
fn test_html5_state_is_tracked_from_reports() {
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
|
||||
// No HTML5 media reported yet: the native backend stays authoritative.
|
||||
assert!(!controller.is_html5_active());
|
||||
|
||||
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||
assert!(controller.is_html5_active());
|
||||
assert!(controller.html5_is_playing());
|
||||
|
||||
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||
assert!(controller.is_html5_active());
|
||||
assert!(!controller.html5_is_playing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_toggle_from_paused_emits_play_control() {
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||
|
||||
controller.toggle_playback().unwrap();
|
||||
|
||||
let controls: Vec<_> = emitter
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(controls, vec!["play".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_toggle_from_playing_emits_pause_control() {
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||
|
||||
controller.toggle_playback().unwrap();
|
||||
|
||||
let controls: Vec<_> = emitter
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(controls, vec!["pause".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
|
||||
// The loop signature: two intents in quick succession must NOT both
|
||||
// resolve the same way, and must not produce opposing actions from a
|
||||
// stale read. Rust's own tracked state makes the sequence deterministic
|
||||
// as long as the element reports back between intents.
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||
|
||||
controller.toggle_playback().unwrap();
|
||||
// Element confirms the pause it was told to do.
|
||||
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||
controller.toggle_playback().unwrap();
|
||||
|
||||
let controls: Vec<_> = emitter
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_play_and_pause_emit_control_commands() {
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||
|
||||
controller.play().unwrap();
|
||||
controller.pause().unwrap();
|
||||
|
||||
let controls: Vec<_> = emitter
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
||||
// When webview video goes away, transport must fall back to the native
|
||||
// backend (music playback must not keep emitting ControlCommands).
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
|
||||
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||
assert!(controller.is_html5_active());
|
||||
|
||||
controller.report_html5_state("stopped".to_string(), None);
|
||||
assert!(!controller.is_html5_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html5_transport_emits_exactly_one_control_per_intent() {
|
||||
// Guards against a double-drive on platforms where the *backend* is also
|
||||
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
|
||||
// must replace the backend call, not run in addition to it.
|
||||
let controller = PlayerController::default();
|
||||
let emitter = Arc::new(CapturingEmitter::new());
|
||||
controller.set_event_emitter(emitter.clone());
|
||||
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||
|
||||
controller.pause().unwrap();
|
||||
|
||||
let controls = emitter
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
|
||||
.count();
|
||||
assert_eq!(controls, 1, "one intent must produce exactly one control");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_volume_default() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.7",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
clampSeekTarget,
|
||||
isSynthesizedTouchClick,
|
||||
isControlSurfaceTouch,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
type TapFeedback,
|
||||
@@ -111,7 +114,9 @@
|
||||
let touchStartY = $state(0);
|
||||
let touchStartTime = $state(0);
|
||||
let tapGestures = createTapGestureState();
|
||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// When a touch tap last ran the gesture handler, so the compatibility click
|
||||
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
|
||||
let lastTouchTapAt = 0;
|
||||
let brightness = $state(1); // 0-2, default 1
|
||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -685,15 +690,19 @@
|
||||
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
||||
}
|
||||
|
||||
console.log("[VideoPlayer Debug]", {
|
||||
currentTime: videoElement.currentTime.toFixed(2),
|
||||
displayTime: currentTime.toFixed(2),
|
||||
buffered: bufferedRanges.join(", "),
|
||||
readyState: videoElement.readyState,
|
||||
paused: videoElement.paused,
|
||||
seeking: videoElement.seeking,
|
||||
playbackRate: videoElement.playbackRate,
|
||||
});
|
||||
// Flattened to a single string on purpose: the Android WebView console
|
||||
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||
// this whole payload useless when diagnosing over adb.
|
||||
console.log(
|
||||
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||
` display=${currentTime.toFixed(2)}` +
|
||||
` readyState=${videoElement.readyState}` +
|
||||
` networkState=${videoElement.networkState}` +
|
||||
` paused=${videoElement.paused}` +
|
||||
` seeking=${videoElement.seeking}` +
|
||||
` rate=${videoElement.playbackRate}` +
|
||||
` buffered=${bufferedRanges.join(", ")}`
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
@@ -714,11 +723,6 @@
|
||||
if (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);
|
||||
@@ -1100,6 +1104,21 @@
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
// The element pausing is normally user intent, but a stall, a source change,
|
||||
// or a competing controller can also do it — and the pause itself carries no
|
||||
// reason. Log the element state so an unexplained pause/resume loop can be
|
||||
// attributed from an adb capture instead of guessed at.
|
||||
const el = videoElement;
|
||||
console.log(
|
||||
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||
` readyState=${el?.readyState}` +
|
||||
` networkState=${el?.networkState}` +
|
||||
` seeking=${el?.seeking}` +
|
||||
` ended=${el?.ended}` +
|
||||
` isSeeking=${isSeeking}` +
|
||||
` isBuffering=${isBuffering}` +
|
||||
` handoff=${handoffState.active}`
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
@@ -1147,7 +1166,10 @@
|
||||
|
||||
async function handleSeekBarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const targetTime = parseFloat(input.value);
|
||||
// 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,
|
||||
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
|
||||
const targetTime = clampSeekTarget(parseFloat(input.value), duration);
|
||||
|
||||
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||
isSeeking = true;
|
||||
@@ -1421,8 +1443,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from the touch target collecting the tag/attribute pairs
|
||||
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
|
||||
*/
|
||||
function ancestorChain(target: EventTarget | null) {
|
||||
const chain: Array<{
|
||||
tag: string;
|
||||
isPlayerControls?: boolean;
|
||||
isPlayerSurface?: boolean;
|
||||
}> = [];
|
||||
let node = target as HTMLElement | null;
|
||||
// Bounded walk: controls live a few levels below the player root, and
|
||||
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
|
||||
while (node && node.tagName !== "BODY") {
|
||||
chain.push({
|
||||
tag: node.tagName ?? "",
|
||||
isPlayerControls: node.dataset?.playerControls !== undefined,
|
||||
isPlayerSurface: node.dataset?.playerSurface !== undefined,
|
||||
});
|
||||
node = node.parentElement;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
// Touch gesture handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
// Taps on the controls belong to those controls. This listener is on the
|
||||
// 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
|
||||
// two cancelling out and leaving the control apparently dead (DR-098).
|
||||
if (isControlSurfaceTouch(ancestorChain(e.target))) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
@@ -1434,26 +1486,23 @@
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
// Suppress the compatibility click this touch will synthesize.
|
||||
lastTouchTapAt = Date.now();
|
||||
|
||||
if (outcome.action === "seek") {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
|
||||
// leaves the play state as it was (playing keeps playing, paused stays
|
||||
// paused).
|
||||
if (outcome.togglePlayPause) togglePlayPause();
|
||||
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())) {
|
||||
// First tap: act now. Nothing is deferred, so there is no timer to race the
|
||||
// compatibility click Android synthesizes after a touch tap (see DR-098).
|
||||
togglePlayPause();
|
||||
}
|
||||
}, outcome.pendingAfterMs);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!e.touches[0]) return;
|
||||
@@ -1465,14 +1514,16 @@
|
||||
|
||||
// Minimum movement to register as swipe (50px)
|
||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||
swipeGestureActive = true;
|
||||
|
||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||
// Only on the frame the gesture is first recognised as a swipe — this runs
|
||||
// on every touchmove, and the correction below must happen exactly once.
|
||||
if (!swipeGestureActive) {
|
||||
// The touchstart already toggled play/pause (taps act immediately now),
|
||||
// so undo it: a swipe must not change the play state. Forget the tap too,
|
||||
// so it cannot pair with a later tap into a spurious seek.
|
||||
togglePlayPause();
|
||||
tapGestures.cancel();
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
swipeGestureActive = true;
|
||||
|
||||
// Brightness control on vertical swipe
|
||||
swipeType = "brightness";
|
||||
@@ -1491,14 +1542,16 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||
* 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.
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
|
||||
* `handleTouchStart`, so the compatibility click the browser synthesizes after
|
||||
* a tap must be ignored or every tap toggles twice.
|
||||
*
|
||||
* Used by EVERY click target layered over the video, not just the <video>:
|
||||
* pausing renders the full-screen play overlay, so the synthesized click lands
|
||||
* on that button instead and would re-toggle straight back to playing.
|
||||
*/
|
||||
function handleVideoClick(e: MouseEvent) {
|
||||
// A click synthesized from a touch reports no pointer movement detail.
|
||||
if (e.detail === 0 || tapTimeout !== null) return;
|
||||
function handleSurfaceClick(e: MouseEvent) {
|
||||
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
@@ -1666,7 +1719,7 @@
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleVideoClick}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
@@ -1759,10 +1812,17 @@
|
||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if !isPlaying}
|
||||
<!-- Play/Pause overlay -->
|
||||
<!-- Play overlay. Visually this IS the video surface, so it is marked
|
||||
`data-player-surface`: it must keep participating in tap gestures even
|
||||
though it is a <button>, or the second tap of a double tap (which lands
|
||||
here, because the first tap paused and raised this overlay) is
|
||||
discarded as "a tap on a control" and seeking dies. It still shares the
|
||||
synthesized-click guard, since it appears exactly when a tap pauses.
|
||||
See DR-098. -->
|
||||
<button
|
||||
data-player-surface
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||
onclick={togglePlayPause}
|
||||
onclick={handleSurfaceClick}
|
||||
aria-label="Play"
|
||||
>
|
||||
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -1810,8 +1870,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||
container-level tap gestures ignore touches here (see DR-098). -->
|
||||
<div
|
||||
data-player-controls
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Behavioural regression tests for the video tap surface — rendered against the
|
||||
* REAL component, not a hand-modelled DOM.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-098 | UT-092
|
||||
*
|
||||
* Why this file exists:
|
||||
*
|
||||
* `tapGestures.test.ts` tests `registerTap` / `isControlSurfaceTouch` /
|
||||
* `isSynthesizedTouchClick` as isolated pure functions. Every one of those tests
|
||||
* passed while, on the device, in sequence: the player pause-looped, then
|
||||
* pausing became impossible, then the bottom controls went dead, then
|
||||
* double-tap-to-seek stopped working. The helpers were each behaving exactly as
|
||||
* specified — the bugs were all in the *composition*: which element actually
|
||||
* receives a tap once Svelte has re-rendered.
|
||||
*
|
||||
* Testing my own helpers could not catch that, and modelling the DOM by hand in
|
||||
* a test just re-encodes the same wrong assumption. So these tests render
|
||||
* VideoPlayer and dispatch real touch/click events at whatever element is
|
||||
* genuinely on top, asserting user-visible outcomes ("a double tap seeks")
|
||||
* rather than internals.
|
||||
*
|
||||
* The specific traps encoded here, each a bug that shipped:
|
||||
* - pausing renders a full-screen <button> play overlay OVER the video, so the
|
||||
* second tap of a double tap lands on a button, not the video;
|
||||
* - the browser synthesizes a `click` after a touch tap, which must not toggle
|
||||
* a second time, on ANY layered target;
|
||||
* - the bottom controls bar must drive its own buttons and NOT the container's
|
||||
* tap gestures.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { tick } from "svelte";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||
|
||||
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
||||
|
||||
const toggleSpy = vi.fn();
|
||||
const seekVideoSpy = vi.fn();
|
||||
const seekSpy = vi.fn();
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
|
||||
|
||||
vi.mock("$lib/player", () => ({
|
||||
playerController: {
|
||||
toggle: (...a: unknown[]) => {
|
||||
toggleSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
seekVideo: (...a: unknown[]) => {
|
||||
seekVideoSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
seek: (...a: unknown[]) => {
|
||||
seekSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
setActiveAdapter: vi.fn(),
|
||||
clearActiveAdapter: vi.fn(),
|
||||
getActiveAdapter: vi.fn(() => null),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/player/adapters/rustReportHost", () => ({
|
||||
createRustReportHost: () => ({
|
||||
onState: vi.fn(),
|
||||
onPosition: vi.fn(),
|
||||
onMediaLoaded: vi.fn(),
|
||||
onEnded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onStreamUrlChanged: vi.fn(),
|
||||
onBuffering: vi.fn(),
|
||||
onReady: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/player/html5Adapter", () => ({
|
||||
reportState: vi.fn(),
|
||||
reportPosition: vi.fn(),
|
||||
reportMediaLoaded: vi.fn(),
|
||||
resetReporting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
|
||||
subscribe: (fn: (v: unknown) => void) => {
|
||||
fn({ isAuthenticated: true });
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MEDIA = {
|
||||
id: "item-1",
|
||||
name: "Test Episode",
|
||||
type: "Episode",
|
||||
runTimeTicks: 6_000_000_000, // 600s
|
||||
} as any;
|
||||
|
||||
/** Dispatch a touch at (x, y) on whatever element is topmost there. */
|
||||
function touchAt(el: Element, x: number) {
|
||||
const touch = { clientX: x, clientY: 300 } as Touch;
|
||||
el.dispatchEvent(
|
||||
new TouchEvent("touchstart", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [touch] as unknown as Touch[],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderPlayer() {
|
||||
return render(VideoPlayer, {
|
||||
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
|
||||
});
|
||||
}
|
||||
|
||||
describe("VideoPlayer tap surface (real component)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
|
||||
touchAt(video!, 900);
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the synthesized click after a tap does not toggle a second time", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
|
||||
touchAt(video, 900);
|
||||
// The compatibility click the browser fires after a touch tap. detail=0 is
|
||||
// how engines mark it; a late real-detail click is covered by the recency
|
||||
// guard, which this exercises too since it lands immediately.
|
||||
video.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a double tap seeks even though the first tap raised the play overlay", async () => {
|
||||
// THE regression this file exists for. On device the first tap pauses, which
|
||||
// makes Svelte render a full-screen <button> play overlay over the video —
|
||||
// so the SECOND tap lands on a button, not the video. A control-surface
|
||||
// guard that does not know about that overlay discards it and seeking dies.
|
||||
//
|
||||
// Reproducing it requires the overlay to actually render, which means
|
||||
// driving `isPlaying` the way the real element does: via its `pause` event.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
|
||||
// Tap 1 on the video.
|
||||
touchAt(video, 900);
|
||||
|
||||
// The element reports it paused → isPlaying=false → overlay renders.
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
|
||||
const overlay = container.querySelector("[data-player-surface]");
|
||||
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
|
||||
// Tap 2 lands on the OVERLAY, exactly as on device.
|
||||
touchAt(overlay!, 900);
|
||||
|
||||
// Either seek route is acceptable — which one runs depends on whether a
|
||||
// video adapter is registered. What must hold is that a seek happened, to
|
||||
// roughly the forward-skip target.
|
||||
const calls = [...seekVideoSpy.mock.calls, ...seekSpy.mock.calls];
|
||||
expect(calls.length).toBe(1);
|
||||
const [position] = calls[0];
|
||||
expect(position).toBeGreaterThan(0);
|
||||
expect(position).toBeLessThanOrEqual(SEEK_FORWARD_SECONDS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("tapping the bottom play/pause button toggles once, not twice", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const controls = container.querySelector("[data-player-controls]");
|
||||
expect(controls).toBeTruthy();
|
||||
|
||||
const playBtn = controls!.querySelector("button");
|
||||
expect(playBtn).toBeTruthy();
|
||||
|
||||
// A real press: touchstart bubbles to the container's gesture handler, then
|
||||
// the button's own click fires. Only ONE toggle may result.
|
||||
touchAt(playBtn!, 40);
|
||||
playBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
clampSeekTarget,
|
||||
END_SEEK_MARGIN_SECONDS,
|
||||
isSynthesizedTouchClick,
|
||||
isControlSurfaceTouch,
|
||||
TOUCH_CLICK_SUPPRESS_MS,
|
||||
} from "./tapGestures";
|
||||
|
||||
const SCREEN_WIDTH = 1000;
|
||||
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
|
||||
}
|
||||
|
||||
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);
|
||||
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
|
||||
//
|
||||
// 1st tap: toggle play/pause
|
||||
// 2nd tap: seek, then toggle play/pause AGAIN
|
||||
//
|
||||
// The second toggle undoes the first, so a double tap seeks while leaving the
|
||||
// play state exactly as it was: playing -> jump and keep playing; paused ->
|
||||
// jump and stay paused. The old design deferred the first tap behind a 300ms
|
||||
// timer, which raced the synthesized click and produced a pause/unpause loop.
|
||||
|
||||
// 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("toggles play/pause immediately on the first tap", () => {
|
||||
const state = createTapGestureState();
|
||||
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1150));
|
||||
@@ -50,12 +53,11 @@ describe("tap gesture resolution", () => {
|
||||
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();
|
||||
// The re-toggle is what preserves the play state across a double tap.
|
||||
expect(second.togglePlayPause).toBe(true);
|
||||
});
|
||||
|
||||
it("seeks back 10s on a double tap on the left half", () => {
|
||||
it("seeks back 10s AND toggles again on a second left-side tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, LEFT, 1100));
|
||||
@@ -63,24 +65,44 @@ describe("tap gesture resolution", () => {
|
||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(-10);
|
||||
expect(second.feedback).toBe("left");
|
||||
expect(second.togglePlayPause).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a second tap after the window as a new pending single tap", () => {
|
||||
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
|
||||
const state = createTapGestureState();
|
||||
let playing = true;
|
||||
const apply = (outcome: ReturnType<typeof tap>) => {
|
||||
if (outcome.action === "togglePlayPause") playing = !playing;
|
||||
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
|
||||
};
|
||||
|
||||
apply(tap(state, RIGHT, 1000)); // toggle -> paused
|
||||
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
|
||||
expect(playing).toBe(true);
|
||||
|
||||
// And from paused, a double tap leaves it paused.
|
||||
playing = false;
|
||||
apply(tap(state, RIGHT, 2000));
|
||||
apply(tap(state, RIGHT, 2100));
|
||||
expect(playing).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a tap after the window as a fresh first tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||
|
||||
expect(late.action).toBe("pending");
|
||||
expect(late.action).toBe("togglePlayPause");
|
||||
});
|
||||
|
||||
it("does not treat a third tap as another double tap", () => {
|
||||
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
|
||||
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");
|
||||
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
|
||||
// play/pause — there is no "third tap" concept.
|
||||
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
|
||||
});
|
||||
|
||||
it("accumulates repeated double taps on the same side", () => {
|
||||
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
|
||||
expect(second.feedback).toBe("right");
|
||||
});
|
||||
|
||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
state.cancel();
|
||||
|
||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
// Without cancel() this would have been the seeking second tap.
|
||||
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +146,28 @@ describe("seek target resolution", () => {
|
||||
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("clamps short of the duration when skipping past the end", () => {
|
||||
// Never land exactly on `duration`: hls.js would then request the segment
|
||||
// that starts at/after the media end, which the server never produces —
|
||||
// the fetch times out and the gap-controller stalls in a pause loop.
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
|
||||
DURATION - END_SEEK_MARGIN_SECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
|
||||
// Regression: seeking near the end of a ~105min transcoded item clamped to
|
||||
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
|
||||
// starts at 6336.33s — past the end. That segment 404s/times out forever.
|
||||
const runtime = 6330.324;
|
||||
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
|
||||
|
||||
expect(target).toBeLessThan(runtime);
|
||||
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
|
||||
});
|
||||
|
||||
it("does not clamp below zero for media shorter than the end margin", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
|
||||
});
|
||||
|
||||
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||
@@ -156,3 +199,78 @@ describe("seek target resolution", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control-surface touches are not gestures", () => {
|
||||
// Regression: the gesture listener is on the outer container and touch events
|
||||
// bubble, so tapping the bottom play/pause button ran the gesture handler
|
||||
// (toggle #1) AND the button's own click handler (toggle #2). The two
|
||||
// cancelled out and the control appeared dead.
|
||||
it("treats a tap on a button as a control, not a gesture", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the seek bar input as a control", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats anything inside the controls bar as a control", () => {
|
||||
expect(
|
||||
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a tap on the bare video surface through as a gesture", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive about tag names", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesized touch-click suppression", () => {
|
||||
// Regression: pausing renders a full-screen play-overlay button over the
|
||||
// video, so the compatibility click Android synthesizes from the tap lands on
|
||||
// the OVERLAY, not the <video>. With no guard there it re-toggled and undid
|
||||
// the pause — pausing looked impossible while unpausing worked fine (the
|
||||
// overlay is removed when playing, so nothing intercepted that direction).
|
||||
it("suppresses a click with detail 0 (clearly synthesized)", () => {
|
||||
expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a real-detail click that closely follows a touch tap", () => {
|
||||
const tapAt = 10_000;
|
||||
expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
|
||||
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a genuine mouse click well after any touch", () => {
|
||||
const tapAt = 10_000;
|
||||
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a genuine mouse click when no touch has ever happened", () => {
|
||||
expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
|
||||
it("keeps a mid-stream target untouched", () => {
|
||||
expect(clampSeekTarget(100, 600)).toBe(100);
|
||||
});
|
||||
|
||||
it("pulls a drag to the very end back inside the media", () => {
|
||||
// The seek bar's max IS the duration, so dragging fully right yields
|
||||
// exactly `duration` — the value that triggers the dead-segment stall.
|
||||
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
|
||||
});
|
||||
|
||||
it("clamps negative and non-finite targets to zero", () => {
|
||||
expect(clampSeekTarget(-5, 600)).toBe(0);
|
||||
expect(clampSeekTarget(NaN, 600)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the target alone when the duration is unknown", () => {
|
||||
expect(clampSeekTarget(500, 0)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
/**
|
||||
* 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.
|
||||
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
|
||||
* deferral:
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||
* 1st tap: toggle play/pause
|
||||
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
|
||||
*
|
||||
* The second toggle undoes the first, so a double tap seeks while leaving the
|
||||
* play state exactly as it started — playing stays playing, paused stays paused.
|
||||
*
|
||||
* This replaced a design that deferred the first tap behind a 300ms timer so it
|
||||
* could be cancelled if a second tap arrived. That deferral raced the
|
||||
* compatibility `click` Android's WebView synthesizes after a touch tap: the
|
||||
* timer cleared its own handle *before* running the toggle, reopening the guard
|
||||
* that was meant to suppress the late click, which then toggled a second time.
|
||||
* The result was a play/pause loop about a second apart. Acting immediately
|
||||
* removes the timer, the window race, and the loop.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
|
||||
*/
|
||||
|
||||
/** A second tap within this window makes a double tap. */
|
||||
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
|
||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
/**
|
||||
* How long after a touch tap a mouse `click` is assumed to be the compatibility
|
||||
* event the browser synthesizes from that touch. Android's WebView can deliver it
|
||||
* noticeably late, so this is generous.
|
||||
*/
|
||||
export const TOUCH_CLICK_SUPPRESS_MS = 700;
|
||||
|
||||
/**
|
||||
* Whether a touch landed on an interactive control rather than the bare video
|
||||
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
|
||||
*
|
||||
* The gesture listener sits on the outer container, and touch events bubble, so
|
||||
* without this a tap on the bottom control bar runs the gesture handler (toggle
|
||||
* #1) *and* the button's own click handler (toggle #2) — the two cancel out and
|
||||
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
|
||||
* inside an element marked `data-player-controls` are treated as controls.
|
||||
*
|
||||
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
|
||||
* testable without a DOM.
|
||||
*/
|
||||
export function isControlSurfaceTouch(
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
|
||||
): boolean {
|
||||
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
|
||||
for (const node of ancestors) {
|
||||
// `data-player-surface` wins over the tag check: the full-screen play overlay
|
||||
// is a <button> but is visually the video itself, and must keep taking tap
|
||||
// gestures — otherwise the second tap of a double tap (which lands on it,
|
||||
// because the first tap paused and raised it) is discarded and seeking dies.
|
||||
if (node.isPlayerSurface === true) return false;
|
||||
if (node.isPlayerControls === true) return true;
|
||||
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `click` should be ignored because a touch tap already handled it.
|
||||
*
|
||||
* EVERY click target layered over the video must consult this — not just the
|
||||
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
|
||||
* synthesized click lands on *that* button rather than the video, and an
|
||||
* unguarded handler there re-toggles and undoes the pause (pause appeared
|
||||
* impossible while unpause worked, because unpausing removes the overlay).
|
||||
*
|
||||
* `detail === 0` catches the synthesized click on engines that report it; the
|
||||
* recency check covers engines that report a real `detail`.
|
||||
*/
|
||||
export function isSynthesizedTouchClick(
|
||||
detail: number,
|
||||
now: number,
|
||||
lastTouchTapAt: number
|
||||
): boolean {
|
||||
if (detail === 0) return true;
|
||||
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
|
||||
}
|
||||
|
||||
/** Double tap on the right half: skip forward. */
|
||||
export const SEEK_FORWARD_SECONDS = 30;
|
||||
|
||||
@@ -22,9 +90,18 @@ 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 };
|
||||
/** First tap: toggle play/pause right now. */
|
||||
| { action: "togglePlayPause" }
|
||||
/**
|
||||
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
|
||||
* undone and the play state survives the double tap unchanged.
|
||||
*/
|
||||
| {
|
||||
action: "seek";
|
||||
seekSeconds: number;
|
||||
feedback: TapFeedback;
|
||||
togglePlayPause: true;
|
||||
};
|
||||
|
||||
export interface TapInput {
|
||||
/** Tap x position, viewport pixels. */
|
||||
@@ -35,32 +112,20 @@ export interface TapInput {
|
||||
|
||||
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).
|
||||
* Forget the previous tap, so the next one is treated as a first tap. Used
|
||||
* when the gesture turns out to be a swipe.
|
||||
*/
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Classify a tap and return the action to perform *now*.
|
||||
*
|
||||
* A tap that closely follows another is the second of a pair: it seeks and
|
||||
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
|
||||
* first tap and simply toggles. Nothing is deferred, so there is no window to
|
||||
* race and no third-tap case — a consumed pair resets the state.
|
||||
*/
|
||||
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
|
||||
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
|
||||
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" };
|
||||
? {
|
||||
action: "seek",
|
||||
seekSeconds: SEEK_BACKWARD_SECONDS,
|
||||
feedback: "left",
|
||||
togglePlayPause: true,
|
||||
}
|
||||
: {
|
||||
action: "seek",
|
||||
seekSeconds: SEEK_FORWARD_SECONDS,
|
||||
feedback: "right",
|
||||
togglePlayPause: true,
|
||||
};
|
||||
}
|
||||
|
||||
s.lastTapTime = input.now;
|
||||
s.pendingSince = input.now;
|
||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||
return { action: "togglePlayPause" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety margin (seconds) kept between a clamped seek target and the media end.
|
||||
*
|
||||
* Landing *exactly* on `duration` makes hls.js request the segment whose start
|
||||
* time is at/after the end of the media. The server never produces that segment,
|
||||
* so the fetch times out and hls.js' gap-controller stalls forever at the last
|
||||
* buffered position — surfacing as "unpausing bounces straight back to paused".
|
||||
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
|
||||
* the final segment boundary.
|
||||
*/
|
||||
export const END_SEEK_MARGIN_SECONDS = 6;
|
||||
|
||||
/**
|
||||
* Clamp an absolute seek target into the safely-playable range.
|
||||
*
|
||||
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
|
||||
* drag path, which can otherwise land exactly on `duration` because the range
|
||||
* input's `max` is the duration itself.
|
||||
*/
|
||||
export function clampSeekTarget(target: number, duration: number): number {
|
||||
if (!Number.isFinite(target) || target < 0) return 0;
|
||||
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
|
||||
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface SeekTargetInput {
|
||||
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
|
||||
// going negative on media shorter than the margin itself.
|
||||
if (duration > 0 && target > duration) {
|
||||
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError"
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||
* produces it routinely, so it must not reach the player's error channel.
|
||||
*/
|
||||
function isPlayInterruptedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const { name, message } = err as { name?: string; message?: string };
|
||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||
private pendingPlay: Promise<void> | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||
// calls is what turns one stall into an AbortError storm.
|
||||
if (this.pendingPlay) return this.pendingPlay;
|
||||
|
||||
this.pendingPlay = (async () => {
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||
// nudges the element to recover from a stall, which cancels the pending
|
||||
// play promise while the element keeps trying. Surfacing it would report
|
||||
// an error roughly once a second for the duration of the stall.
|
||||
if (isPlayInterruptedError(err)) {
|
||||
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
||||
} else {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.pendingPlay = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingPlay;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
|
||||
+15
-4
@@ -12,7 +12,7 @@
|
||||
* derived + merged (remote-session-aware) stores so UI can import state and
|
||||
* actions from one place, in both local and remote modes.
|
||||
*
|
||||
* TRACES: UR-005 | DR-001, DR-009
|
||||
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-091
|
||||
*/
|
||||
|
||||
import { get } from "svelte/store";
|
||||
@@ -83,18 +83,29 @@ function requireHandle(): string {
|
||||
// Transport controls (no repository handle required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
|
||||
//
|
||||
// These used to short-circuit into the active video adapter, which made the
|
||||
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
|
||||
// flipped the element, so Rust never saw the intent. `el.paused` flips
|
||||
// transiently while an element buffers or settles a seek, so two intents
|
||||
// ~150ms apart could read different values and take opposing actions — a
|
||||
// self-sustaining play/pause loop.
|
||||
//
|
||||
// Now Rust decides from PlayerController state and drives the element back
|
||||
// through a `ControlCommand` event (handled in playerEvents.ts), the same
|
||||
// "backend decides, adapter executes the primitive" split used by
|
||||
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
|
||||
|
||||
async function play() {
|
||||
if (activeAdapter) return void (await activeAdapter.play());
|
||||
await commands.playerPlay();
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
if (activeAdapter) return void (await activeAdapter.pause());
|
||||
await commands.playerPause();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
||||
await commands.playerToggle();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
|
||||
*
|
||||
* TRACES: UR-005 | DR-097 | UT-091
|
||||
*
|
||||
* The frontend used to short-circuit transport controls whenever a video adapter
|
||||
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
|
||||
* directly, so the Rust `PlayerController` never saw the intent and could not
|
||||
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
|
||||
* element buffers or settles a seek, two intents arriving ~150ms apart could read
|
||||
* *different* values and perform *opposing* actions — one playing, one pausing —
|
||||
* which is the self-sustaining play/pause loop observed on Android.
|
||||
*
|
||||
* The rule these tests pin: a transport intent always reaches the backend. Rust
|
||||
* decides play-vs-pause from controller state and drives the webview element back
|
||||
* through a ControlCommand event (the same "backend decides, adapter executes"
|
||||
* split `player_seek_video` already uses).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCommands = {
|
||||
playerPlay: vi.fn(async () => ({})),
|
||||
playerPause: vi.fn(async () => ({})),
|
||||
playerToggle: vi.fn(async () => ({})),
|
||||
playerStop: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: mockCommands,
|
||||
// Stores pulled in transitively subscribe to typed events at module load.
|
||||
events: {
|
||||
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||
downloadEvent: { listen: vi.fn(async () => () => {}) },
|
||||
searchEvent: { listen: vi.fn(async () => () => {}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
subscribe: (fn: (v: unknown) => void) => {
|
||||
fn({ isAuthenticated: true });
|
||||
return () => {};
|
||||
},
|
||||
getRepository: () => ({ getHandle: () => "handle-1" }),
|
||||
},
|
||||
}));
|
||||
|
||||
/** A video adapter that records whether the facade reached into it directly. */
|
||||
function makeAdapter() {
|
||||
return {
|
||||
kind: "html5" as const,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(async () => {}),
|
||||
toggle: vi.fn(async () => true),
|
||||
seekElement: vi.fn(async () => {}),
|
||||
reloadSource: vi.fn(async () => {}),
|
||||
attach: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setVolume: vi.fn(),
|
||||
setMuted: vi.fn(),
|
||||
selectSubtitle: vi.fn(async () => {}),
|
||||
getPosition: vi.fn(() => 0),
|
||||
load: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("transport authority lives in Rust", () => {
|
||||
let playerController: any;
|
||||
let adapter: ReturnType<typeof makeAdapter>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
({ playerController } = await import("./index"));
|
||||
adapter = makeAdapter();
|
||||
playerController.setActiveAdapter(adapter);
|
||||
});
|
||||
|
||||
it("routes toggle to the backend even when a video adapter is active", async () => {
|
||||
await playerController.toggle();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
// The webview must NOT decide play-vs-pause from the DOM.
|
||||
expect(adapter.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes play to the backend even when a video adapter is active", async () => {
|
||||
await playerController.play();
|
||||
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes pause to the backend even when a video adapter is active", async () => {
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
|
||||
playerController.clearActiveAdapter();
|
||||
|
||||
await playerController.toggle();
|
||||
await playerController.play();
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* frontend stores accordingly. This enables push-based updates instead
|
||||
* of polling.
|
||||
*
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
|
||||
*/
|
||||
|
||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
||||
|
||||
/**
|
||||
* Route a backend-originated control command to the active player adapter, so a
|
||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
||||
* playback is already fully backend-driven).
|
||||
* backend intent can drive the webview <video>/<audio> element that Rust cannot
|
||||
* reach directly. No-op when no adapter is active (native playback is already
|
||||
* fully backend-driven).
|
||||
*
|
||||
* This is the EXECUTION half of transport authority: for webview-rendered media
|
||||
* the Rust controller decides play-vs-pause from the state the element reported
|
||||
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
|
||||
* facade in $lib/player) and come back through this path — never short-circuited
|
||||
* in the webview, which is what caused the DR-097 pause loop.
|
||||
*/
|
||||
function handleControlCommand(action: string, position: number | null): void {
|
||||
const adapter = playerController.getActiveAdapter();
|
||||
|
||||
Reference in New Issue
Block a user