Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.
StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.
The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile is what makes the server refuse
to direct-play a file fatter than the cap, and without it a 30 Mbps
remux is handed over untouched and every URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.
The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.
Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.
Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.
TRACES: UR-074 | DR-160 | UT-156, UT-157
Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.
The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.
Replace the deferral with immediate action — there are only first and
second taps:
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 jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.
Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.
A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.
UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.
el.paused flips transiently while an element buffers or settles a seek.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.
The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.
Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.
Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
Establish a decoupled player boundary so UI and backend interact with video
through one contract, with the HTML5 (Linux/interim-Android) and native
(ExoPlayer) providers as interchangeable primitive-executor adapters.
- PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the
adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource,
play/pause, setVolume, selectSubtitle); it never branches on strategy.
- Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track
return a strategy); the facade dispatches the chosen primitive to the active
adapter. Both providers share the one decision path — logic lives once, in Rust.
- Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets
backend control (lockscreen/remote/sleep) drive the webview <video> element.
- Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause
silently no-opping when the element was re-bound).
- Do not emit a "stopped" player state on natural end-of-video: it flipped the
player/mode to idle mid-handoff and suppressed next-episode auto-advance under
a sleep timer. Jellyfin progress reporting is preserved; the backend's
on_video_playback_ended owns the transition.
- VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter).
- Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>