Compare commits

..
Author SHA1 Message Date
dtourolle 7545de6cc7 refactor: delete two orphans, and record why the reparent design changed
`resolveVideoSource` chose between a local file and a remote URL for video
playback. Backend-owned stream selection took that decision into Rust —
`media_local_selection` for a downloaded file, `get_stream_selection` for a
streamed one — and its last caller went with it. What remained was the function
plus sixty lines of tests exercising nothing that ships.

`fittedVideoSize` computed the rendered size of a video letterboxed into its
container. Nothing has ever called it: it arrived with the fix that made the
video fill its viewport and was superseded by `object-fit: contain` in the same
change. There is some irony in a helper that models letterboxing sitting unused
beside a container that was not letterboxing at all — the bug fixed in the
previous commit was CSS, and this function would not have helped.

A survey for exported symbols referenced only by their own tests finds 22 more.
Most are legitimate — test mocks, deliberate reset hooks, public utility APIs —
and the rest are unrelated to this work, so they are left for a cleanup that can
be reviewed on its own terms rather than smuggled into a playback branch.

Also records in the spec why DR-231's design changed. Reparenting Tauri's
webview into a GtkOverlay aborts the process on the first click: Linux calls
`attach_resize_handler` unconditionally (the Windows path guards it with
`is_decorated()`), and its handler walks webview -> GtkBox -> GtkWindow with an
unwrap that an overlay breaks. So the webview is not moved at all — mpv draws
into the default vbox's own `draw` handler via `gdk_cairo_draw_from_gl()`, and
GTK's container-before-children order puts the webview on top for free. No
reparent, one less widget, and nothing a Tauri upgrade can invalidate by
assuming its own layout.
2026-08-22 13:45:04 +02:00
dtourolle 0445a6d0aa docs(requirements): DR-234 is in progress, not proposed
The renderer-derived codec source was built while chasing four Android bugs
that all turned out to be the same defect, so it landed ahead of the spec that
allocates it. Five call sites now read one source instead of re-deriving or
hardcoding the webview's answer.

Not Done: on Linux it still resolves per platform, because there is still only
one renderer there. It becomes the runtime question the spec describes when mpv
draws the picture.
2026-08-22 13:45:04 +02:00
dtourolle a8c44145ff fix(player): letterbox the picture, and stop racing the session
Two bugs found by resizing the window during playback. Neither was introduced
by this branch; both are the kind that only surface when somebody actually
drags a window edge.

The picture cropped and sat at the top instead of letterboxing. The video's
flex wrapper had no `min-h-0`, and a flex item defaults to `min-height: auto` —
it refuses to shrink below its content's intrinsic size, and a <video> reports
the *media's* natural dimensions. So whenever the picture was larger than the
window the wrapper grew past the viewport, the overflow went off the bottom,
and what was visible was the top-left of an uncentred, uncropped image.
`object-contain` was doing its job the whole time, inside a box that was the
wrong size. This is also what put the picture at the bottom in fullscreen,
reported earlier and unexplained until now.

"Not connected to a server", shown as a *playback* error. The player page asks
for the repository on mount, but the session is restored asynchronously at
startup, so losing that race turned a perfectly good stream into a fatal error
screen. `getRepository()` throwing instantly is right for a click handler,
where the user is present; it is wrong for anything that runs on mount.
`waitForRepository()` resolves as soon as the session lands and still rejects
when there genuinely is not one, so a real logged-out state surfaces — just not
as a race.

Worth recording how this was found, because it was nearly misdiagnosed: the
symptom correlated with window resizes, but the log showed 230 Vite HMR updates
against a single app start — the frontend was being remounted under the test by
edits made while it ran, and a remount empties the in-memory auth store. The
race is real and worth fixing on its own merits, but "resize causes it" was an
artifact of how it was being observed, not a property of the bug.

UT-215 covers the waiting contract: resolves when already restored, resolves
when the session arrives late, still rejects when there is none, unsubscribes
once settled, and leaves no armed timer to reject an already-resolved promise.
2026-08-22 13:45:04 +02:00
dtourolle fecd6022fe chore(traceability): shift this branch's ids clear of master's
Master allocated DR-224 and UT-211 while this branch was in flight — the third
collision on this work. Everything here moves up by one: DR-224..236 become
DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were
still free and are unchanged.

Mechanical, and matched on each row's own text rather than on its number, so a
row cannot be shifted twice or the wrong one caught. Master's DR-224 (the
background-audio toggle) and UT-211 are untouched.
2026-08-22 13:45:04 +02:00
dtourolle 156b9e3684 fix(playback): ask the renderer what it can decode, in one place
Four bugs, one cause. "What can this device decode" was answered in five
places, four of which assumed the webview was decoding:

  - the device profile's direct-play codecs      (cfg per platform, inline)
  - the transcoding targets                       (hardcoded "h264,hevc")
  - the direct-play audio narrowing               (webview list, all platforms)
  - the client-side audio override                (webview list, all platforms)
  - get_video_stream_url's VideoCodec             (hardcoded "h264")

On Android the decoder is ExoPlayer, so four of those were simply wrong there,
and the costs were invisible without a device:

  - dts is in the tablet's own codec list, gets stripped from the profile, and
    is then forced to transcode by a rule about a renderer that is not playing
    it.
  - An hevc source whose *audio* is eac3 had its **picture fully re-encoded**.
    The server's own transcoding URL got this right — VideoCodec=h264,hevc,
    TranscodeReasons=AudioCodecNotSupported, video copied — but the moment a
    quality change or track switch re-opened the stream through our builder,
    the hardcoded h264 turned a cheap audio remux into a full transcode. That
    is a quality change silently making playback more expensive, on the exact
    path a viewer uses when playback is already struggling.

`renderer_codecs()` and `renderer_can_decode_audio()` are now the single
source, and all five sites read them. On the webview path every value resolves
exactly as before, so desktop behaviour is unchanged by construction; on
Android the profile becomes the device's own.

The list is also what lets the server *copy* rather than re-encode: naming
every codec the renderer can decode is what turns a transcode into a
passthrough when the source is already playable. That is the whole of "use the
best format available".

Also corrects this branch's headline number where it is asserted — the
architecture doc, the desktop-native-video spec and the spike. The measured 85%
Android direct-play rate used a profile containing ac3/eac3; the device it was
later verified on reports neither, so eac3 content correctly transcodes there.
It is a ceiling for an ExoPlayer-appropriate profile, not what the app achieves,
and realising any of it depends on this change. Left in place with the caveat
rather than deleted, because the measurement is real — it just measures
something narrower than it was quoted as measuring.

Unverified: this changes what Android negotiates and has not been exercised on
the tablet yet. Desktop is unchanged by construction but also unre-tested.
2026-08-22 13:45:03 +02:00
dtourolle 4f6cf22419 fix(player): tell the native backend's caller what it actually did
Two defects found by running on an Android tablet, both invisible on the
desktop, and both the same mistake: a rule written for the webview applied to a
backend that is not one.

The quality picker froze on the first stream. `StreamQualityResponse::Native`
carried only a position, so nothing replaced the selection the UI holds after a
native quality change. The picker derives the rung in force from that
selection's rendition, and a transcode always has a rendition — so the fallback
that would have used the requested value was never reached. The stream changed
and the menu did not. The native variant now carries the `StreamSelection` the
backend opened, like the HTML5 variant already did.

This was invisible on the desktop because the webview path replaces the
selection as a side effect of reloading its element. It looked correct there for
a reason that does not generalise.

A quality change restarted playback from zero. The resume position came from
`videoElement.currentTime`, which the frontend cannot supply on a native backend
— there is no `<video>` element, so it correctly sends null and the backend
substituted 0. Reading it from the DOM at all inverts the rule that the player
is the authority on playback state; the fallback now asks the controller where
it is. Captured before the negotiation round-trip, so it resumes a few hundred
milliseconds behind rather than ahead, which is the right direction to err.

Also from the tablet, and NOT fixed here because it changes playback behaviour
and deserves its own change: `audio_forces_transcode` judges against
`WEBVIEW_AUDIO_CODECS` on every platform, and `video_audio_codecs` narrows the
advertised direct-play audio set to that same webview list. On Android the
decoder is ExoPlayer. The tablet reports dts among its platform codecs, has it
stripped from the profile, and then has the webview rule force a transcode for
it. That is the third instance of a decode capability tied to the wrong
renderer, and it is what DR-233 exists to collapse — evidence now, not a design
preference.

It also corrects the record on this branch's headline number. The measured 85%
direct-play rate used a hypothetical Android profile including ac3/eac3; this
tablet's MediaCodecList reports neither, so eac3 content — about a third of the
sampled library — correctly transcodes here. 85% was the ceiling of a profile
the app does not send, on hardware that could not use it. The negotiation and
the contract are sound; the figure was not a measurement of what ships.
2026-08-22 13:45:03 +02:00
dtourolle 84cf31b929 feat(video): build the native video surface, and fix what running it exposed
Three things, all found by actually running the app rather than by reading it.

The surface (DR-230). A GtkGLArea as the main child of a GtkOverlay with
Tauri's own webview reparented on top — the desktop shape of what Android
already does with ExoPlayer. It attaches cleanly and is then **off by
default**, because the reparent fails the gate the spike said it would.

`tauri-runtime-wry`'s undecorated-resizing handler walks a hard-coded path on
every button press in the webview:

    webview.parent()   // "This one should be GtkBox"
           .parent()   // ...and this one the GtkWindow
           .downcast::<gtk::Window>().unwrap()

Wrapping the webview makes that chain webview -> GtkOverlay -> GtkBox, the
downcast fails, and the panic is non-unwinding so it aborts the process. The
decoration check that would make the handler inert runs *after* the unwrap, so
no window configuration avoids it. The surface attaching successfully is
therefore not the gate — a click is. It lives behind JELLYTAU_NATIVE_VIDEO=1
with the mechanism written down, because the next attempt needs to keep Tauri's
two-hop shape intact and that is the whole design constraint.

Also settles a dependency question the spike left implied: the render API is
reachable from the pinned libmpv revision. Its safe `render` module is an empty
stub, but libmpv-sys carries every render symbol and `Mpv::ctx` is public, so
the context can be built over the handle the audio backend already drives. This
does not need the libmpv2 migration first.

The HLS effect re-ran on object identity. `currentSelection` is a struct, and
every reload replaces it even when the URL and transport are unchanged — so the
effect tore down hls.js and reattached for an unchanged stream, leaving the
element blank until a seek forced another cycle. The pre-DR-224 code read a
plain URL *string*, where re-assigning the same value was a no-op; the codebase
documents relying on that and swapping in a struct broke it silently. The
loader decision now takes a primitive transport tag, so the component cannot
depend on object identity — the bug is unrepresentable rather than merely
fixed.

The device profile contradicted itself. The direct-play profile claimed h264
alone on the webview path while the transcoding profile said "you may transcode
to h264 or hevc" — telling the server "I cannot play hevc, so re-encode it" and
then "re-encoding it to hevc is fine". Streams came back carrying
VideoCodec=h264,hevc with hevc-level/profile/bitdepth set. When the server took
that option the webview got something it could not decode, which presents as
video stuck on its first frame rather than as an error. Transcode targets are
now derived from the same codec list as direct play, capped to the two codecs a
Jellyfin server actually encodes so a wider decode list never asks for an av1
encode.

That is the third defect in one family: a decode capability stated in more than
one place, with the copies disagreeing. DR-233 exists to collapse them into one
renderer-derived source, and this is evidence for it rather than a preference.

Not fixed here, and worth knowing:

- The requested VideoBitrate is sized to the ceiling, not to the source — a
  2.2 Mbps source was being re-encoded at 19.8 Mbps, roughly 9x. Pre-existing,
  but this branch is the first thing that knows the source bitrate and so the
  first that can cap it.
- The `debug` build type produces an APK with the *release* applicationId:
  `applicationIdSuffix = ".debug"` is present in the canonical gradle and absent
  from the generated copy, though the identical line in the `release` block
  survives. Not caused by our sync, which is a plain cp. Independent of this
  work; it is why the side-by-side release build is the one that installs.
2026-08-22 13:45:03 +02:00
dtourolle 7cc392d78f docs(specs): mpv draws desktop video, and the webview path goes
The spike proved compositing works on Linux, including Wayland, and left two
blockers. One is now closed: DR-228 measured a single EXT-X-STREAM-INF in the
server's master playlist, so there is no adaptive bitrate for mpv to lose and
finding 3 of playback-backend-unification.md is false. The spike is updated to
record that. The other — an unexplained SIGSEGV in a decoder thread — is carried
into the spec as DR-231 rather than chased: the spike had no render-context
teardown at all, which is DR-184 on Android restated, and removing the likeliest
cause is worth doing whether or not it was the cause.

The spec targets every desktop platform rather than Linux alone, because the
maintenance argument runs the other way. Video has three renderers today. A
Linux-only version makes it four, permanently — mpv on Linux, HTML5 on Windows,
ExoPlayer on Android, hls.js underneath — and the webview path then survives
indefinitely because something still needs it. Finishing the job leaves mpv on
desktop and ExoPlayer on Android, and hls.js, html5Adapter.ts, videoLoaderFor
and the <video> element are deleted in a phase that has its own acceptance
criterion so it cannot quietly become "later".

The load-bearing change is DR-233: the device profile stops being a
compile-time platform constant and becomes a property of the renderer that will
decode the stream. The measured 7% desktop direct-play rate and Android's 85%
differ by nothing except which component decodes, so that one change is what
converts the former toward the latter. It looks like configuration and is not —
it decides whether the server re-encodes, and it fails silently when wrong.

Windows is costed rather than waved at: the surface is genuinely different code
(WebView2 in an HWND, not GTK), but everything else is shared, so nothing may be
guarded on cfg!(target_os = "linux"). The real cost is build — libmpv is a
Linux-only dependency while Windows cross-compiles via cargo-xwin, so a Windows
libmpv must reach that build and ship in the NSIS bundle under the LGPL terms
DR-216 already records.

Allocates UR-080, DR-230..236, IR-033. No product code yet.
2026-08-22 13:45:03 +02:00
dtourolle 109700b949 feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.

One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.

Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:

  Linux / WebKitGTK (h264 only, 2ch)          3/40 —  7% direct play
  Android / ExoPlayer (hevc, ac3/eac3, 6ch)  34/40 — 85% direct play

The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.

DR-219  StreamSelection: url + tagged Transport (hls/progressive/localFile)
        + PlaybackKind (directPlay/directStream/transcode) + the negotiated
        rendition + this source's ladder + a needs_transcoding flag derived
        in Rust so the rule is answered once. Both enums are serde-tagged
        so the frontend matches a discriminant, not a substring. The paths
        that never negotiate get the same shape from Rust rather than
        assembling one — media_local_selection for a downloaded file,
        LiveStreamInfo.transport for a live channel — so there is no second
        place where a transport is decided.

DR-220  The ceiling becomes two levels: a durable device default (Settings,
        persisted) and a per-playback override the in-player picker sets.
        The picker had called itself a "this film, this connection" control
        since it was written but wrote the process-wide default, so dropping
        one awkward film to 2 Mbps silently capped every video played
        afterwards for the rest of the process, with Settings still showing
        the old value. The override is cleared whenever playback moves to a
        new item, which stops it surviving into an autoplayed next episode.
        effective_streaming_quality() is the single resolution point.

DR-221  The quality picker is filled from what this media source can offer.
        Rust marks a rung exceeds_source when its ceiling is at or above the
        source's own bitrate — such a rung is another way to spell Original
        — and the frontend does not draw those. Original is never marked; a
        source whose bitrate the server does not report marks nothing, which
        keeps every rung offered.

DR-222  Direct play and direct stream are negotiated, with two client-side
        overrides on top because the server's answer is right about the file
        and wrong about what this app will do with it: undecodable audio
        (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
        codec but ignores its audio codec, so it offers direct play for an
        E-AC-3 track the webview renders in silence) and a viewer-pinned
        audio track the file does not default to. A direct stream is a remux
        and is deliberately not counted as transcoding.

DR-223  Dropped on measurement, not deferred. A master playlist from this
        server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
        the single rendition the request asked for rather than publishing a
        ladder. So there is no adaptation for hls.js to be preserving and
        none mpv would lose — the claim that there was, in
        playback-backend-unification.md, does not hold. Recorded rather than
        deleted because it is a measurement: a server that does publish a
        ladder would change the answer.

DR-224  Every backend consumes the same selection. The queue item carries
        the transport, so player_seek_video picks its seek strategy from the
        backend's decision instead of the last stream_url.contains(".m3u8")
        in the codebase. Items queued by a path that never negotiated carry
        None and fall back to needs_transcoding, which is exact rather than
        a guess because every transcode this app requests is HLS (DR-140).

The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.

Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.

The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.

Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
2026-08-22 13:45:03 +02:00
101 changed files with 7593 additions and 19556 deletions
-266
View File
@@ -1,266 +0,0 @@
name: '📱 Test APK'
# An installable APK from any branch, on demand, without cutting a release.
#
# Why this exists separately from build-release.yml: that workflow is tag-driven,
# builds Linux + Windows + Android and then *creates a release*, which is not
# what you want from a feature branch. This builds one Android APK from whatever
# ref you dispatch it on and hands it back as an artifact.
#
# Deliberately `workflow_dispatch` only — no push trigger. The runner has a
# single slot shared with two other projects, so a build on every feature-branch
# commit would starve everything else. Dispatch it when you actually want to
# install something.
#
# Both variants install as com.dtourolle.jellytau.debug ("JellyTau Debug"),
# side by side with a real install and with their own data directory. Neither
# needs the release signing key.
#
# Getting the APK to somebody else: Gitea artifacts need an account with read
# access to download, so `publish: true` also attaches the APK to a pre-release
# whose assets are a plain public URL. That is the only way an outside tester
# gets the file without being given an account.
on:
workflow_dispatch:
inputs:
variant:
description: 'Which build to produce'
required: true
default: 'side-by-side-release'
type: choice
options:
# R8-minified, exactly what ships, in the debug slot. Use this unless
# you need stack traces: R8 stripping JNI-loaded classes has broken
# release APKs here before, and a plain debug build cannot catch it.
- side-by-side-release
# Unminified. Faster, readable stack traces, but does not exercise
# minification at all.
- debug
abi:
description: 'Target ABI'
required: true
default: 'aarch64'
type: choice
options:
- aarch64
- armv7
- x86_64
publish:
description: 'Also publish as a pre-release, for testers with no Gitea account'
required: false
default: false
type: boolean
concurrency:
# One test build at a time; a newer dispatch supersedes an in-flight one.
group: build-test-apk
cancel-in-progress: true
env:
# Incremental state is never reused between CI runs -- pure disk cost.
CARGO_INCREMENTAL: 0
jobs:
build:
name: Build test APK (${{ inputs.variant }}, ${{ inputs.abi }})
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# set-version.sh derives a dev version from `git describe --tags`, so
# the tags have to be here. A shallow checkout yields 0.0.0.
fetch-depth: 0
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. Same reasoning (and the
# same key) as every other job: that directory is ~16 GB and caching
# it filled the runner's 74 GB disk. Sharing the key means this
# workflow restores what the others saved rather than adding a
# fourth copy of the registry.
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
# Before `android init`: it derives the generated project (including the
# initial versionCode) from tauri.conf.json.
- name: Stamp a dev version
run: ./scripts/set-version.sh
- name: Initialize Android project
run: bun run tauri android init
# Again after init: tauri.properties only exists now, and its
# autogenerated versionCode is neither large enough nor monotonic against
# the 1000 floor already shipped. On a branch this derives from
# `git describe`, so a test APK always sorts above the last release.
- name: Pin a monotonic Android versionCode
run: ./scripts/set-version.sh
# Built through the same script used locally, rather than a hand-rolled
# gradle/tauri invocation. That is what keeps CI and a developer's machine
# producing the same thing -- and the script asserts the applicationId the
# APK actually carries, which has silently regressed before.
- name: Build APK
run: |
if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then
./scripts/build-android.sh release --debug --abi "${{ inputs.abi }}"
else
./scripts/build-android.sh debug --abi "${{ inputs.abi }}"
fi
- name: Collect APK
id: collect
run: |
mkdir -p dist/test-apk
if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then
PATTERN='*-release.apk'
else
PATTERN='*-debug.apk'
fi
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "$PATTERN" | head -1)
if [ -z "$APK" ]; then
echo "❌ No APK produced for variant ${{ inputs.variant }}"
find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true
exit 1
fi
REF_NAME=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-')
OUT="dist/test-apk/jellytau-${REF_NAME}-${GITHUB_SHA::8}-${{ inputs.variant }}.apk"
cp "$APK" "$OUT"
# Report what the thing actually is, not what it was meant to be.
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
"$APKSIGNER" verify --print-certs "$OUT" || echo "⚠️ Could not verify signature"
{
echo "### 📱 Test APK"
echo ""
echo "| | |"
echo "|---|---|"
echo "| Branch | \`${GITHUB_REF#refs/heads/}\` |"
echo "| Commit | \`${GITHUB_SHA::8}\` |"
echo "| Variant | \`${{ inputs.variant }}\` |"
echo "| ABI | \`${{ inputs.abi }}\` |"
echo "| Size | $(du -h "$OUT" | cut -f1) |"
echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |"
echo ""
echo "Installs as \`com.dtourolle.jellytau.debug\` — side by side with a real"
echo "install, with its own data directory. Download the artifact, then:"
echo ""
echo '```'
echo "adb install -r $(basename "$OUT")"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
ls -lah dist/test-apk/
# Deliberately NOT tagged `v*`: that pattern triggers build-release.yml,
# which would run the whole three-platform release matrix and publish a
# real release off a feature branch. The tag here is derived from the
# branch name and carries no version, so nothing else reacts to it.
#
# This also cannot reach existing users. The desktop updater reads a
# static latest.json from the `updater` branch, not the release list, so a
# pre-release published here is invisible to anyone without the link.
- name: Publish as a pre-release
if: ${{ inputs.publish }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
API="${GITHUB_SERVER_URL}/api/v1"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
BRANCH="${GITHUB_REF#refs/heads/}"
TAG="test-$(echo "$BRANCH" | tr '/' '-')"
# printf, not a heredoc: inside a YAML block scalar every line is
# indented, and a heredoc terminator has to sit at column 0.
BODY=$(printf '%s\n' \
"Test build of \`$BRANCH\` at \`${GITHUB_SHA::8}\` — **not a release**." \
"" \
"Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \
"normal install and with its own separate data. Uninstalling it does not touch" \
"the real app." \
"" \
"Variant: \`${{ inputs.variant }}\` · ABI: \`${{ inputs.abi }}\`" \
"" \
"Android will warn about installing from an unknown source; that is expected" \
"for a build signed with a debug key rather than the store key.")
PAYLOAD=$(jq -n \
--arg tag "$TAG" \
--arg name "Test build: $BRANCH" \
--arg body "$BODY" \
--arg target "$GITHUB_SHA" \
'{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:true}')
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")
if [ "$HTTP" = "201" ]; then
RELEASE_ID=$(jq -r '.id' resp.json)
elif [ "$HTTP" = "409" ]; then
# Re-dispatching for the same branch replaces the previous APK rather
# than accumulating one release per attempt.
echo "️ Pre-release $TAG exists; reusing it"
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
for id in $(curl -fsS "$API/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" | jq -r '.[].id'); do
curl -fsS -X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$id" \
-H "Authorization: token $TOKEN" >/dev/null
done
else
echo "❌ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1
fi
for f in dist/test-apk/*.apk; do
echo "⬆️ $(basename "$f")"
curl -fsS -X POST \
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
done
{
echo ""
echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
echo ""
echo "Public link — no Gitea account needed. Delete the release when testing is done."
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: jellytau-test-apk
path: dist/test-apk/
retention-days: 7
-255
View File
@@ -9,261 +9,6 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.11.5
### 🐛 Fixes
- **A video in a picture-in-picture window stays a video.** Watching in a PiP
window would sometimes drop to audio only, and the audio would pick up from
wherever the video had been when the window opened — while the picture itself
had carried on well past that. Two faults, both needed to produce it. The
player's idea of where it is in the video is kept by a loop that only runs
while the app is drawing to the screen, and behind a PiP window it is not:
the position quietly stopped advancing the moment the window opened, and the
one other source that could have kept it current had been written to switch
itself off during playback. Separately, PiP and the "keep the audio playing in
the background" toggle are meant to be alternatives, but only the toggle
enforced that — the PiP button could still be pressed with both armed, and the
single moment-in-time check meant to tell them apart is not always right about
whether a window is open. Opening PiP now turns background audio off, the app
trusts its own record of the window as well as the system's, and the position
keeps advancing whether or not anything is being drawn. The frozen position
also affected the seek bar, resume points and watch progress reported to the
server for as long as a PiP window was open. (UR-004, UR-040, UR-041 →
DR-265, DR-266)
## v0.11.4
### 🐛 Fixes
- **A newly-added album shows up as one album, not as fourteen songs.**
Importing an album filled the whole Recently Added row with that one album,
one card per track, burying everything else added that week. The app does ask
the server to group new tracks under their album, but the server only manages
it for a track whose folder structure actually resolves an album — and older
servers ignore the request altogether. The row is now grouped here as well, so
what it shows is a property of the app rather than of the server it is talking
to: one card per album, opening the album, keeping its artwork and artists, and
standing where the first of its tracks stood so the newest is still first.
Standalone tracks and movies are unaffected. (UR-024 → JA-016)
- **A binge plays on past the end of a season.** Autoplay only ever looked
inside the current season, so the last episode of one was the end of the line:
with the screen locked and background audio playing, that is felt as playback
simply stopping mid-binge, with no visible player to start it again. It now
crosses into the first episode of the next season that has any, skipping empty
seasons and never wandering into Specials. A sleep timer set to end-of-episode
still stops at the boundary — crossing it is autoplay's decision, not the
timer's. (UR-023, UR-040 → DR-263)
- **The episode you just finished is no longer offered as the one to play next.**
Watch an episode to the end, press Back, and the season list still ringed that
same episode as "Up next" and scrolled to it. Nothing recorded the completion
on the device — progress writes saved a position but never marked an episode
watched, and cached server data dropped the watched flag on the way in — while
the server's own "next up" answer is briefly one report behind and named the
episode that had just ended. An episode is now counted as finished either when
the server says so or when its position is past the same 95% mark that already
disqualifies it from "continue watching", and the server's answer is checked
against that before it is accepted. The highlight, the badge, the auto-scroll
and which season opens expanded all follow the episode that really is next —
and the checkmarks in the season list stay put. (UR-025, UR-062 → DR-264)
### 🛠 Development
- **A debug build installs alongside a release build again.** `bun run
android:dev` produced an APK carrying the release application id, so installing
it over a real release build failed outright and the obvious way out was to
uninstall the release app and lose its data. The Tauri CLI rewrites the debug
section of the generated Gradle file to inject its own settings, and that
rewrite dropped the suffix that keeps the two apps apart; the suffix has moved
somewhere the rewrite does not reach, and the build now asserts the id the APK
actually carries rather than assuming it.
## v0.11.3
### 🐛 Fixes
- **The A-Z jump strip no longer runs under the mini player.** On a long
alphabetical list — an album's tracks, the music library — the last few
letters sat behind the mini player and bottom nav, where they could not be
tapped. The strip was sizing itself against the window minus a hardcoded guess
at how tall those bars are, which stopped being true when they became part of
the normal layout instead of floating over it, and was always short by the
height of your phone's navigation bar. It now measures the list it belongs to
and stops exactly where that list stops, whatever is below. (UR-007 → DR-262)
- **Subtitles sit on the picture instead of on a black bar.** Every line arrived
in an opaque box, which is what Android hands back when no captioning
preferences have been set. The box is gone; the text keeps its own outline so
it stays readable over a bright scene. If you *have* set up captions in
Android's accessibility settings, your colours, typeface and edges are still
used — only the background is cleared. (UR-020 → DR-261)
## v0.11.2
### 🐛 Fixes
- **Subtitles appear on screen on Android.** Turning one on did nothing, even
once they were loading again: the player hands finished subtitles to a view
that draws them, and on the native Android path there was no such view — so
every cue was decoded, delivered and dropped. There is one now, sitting over
the picture and under the controls, following the video's shape when the
screen turns. This was hidden behind the loading failure fixed in v0.11.1;
with nothing to select, there had never been a cue to lose. (UR-020, UR-003 →
DR-260)
## v0.11.1
Four fixes. Two had been present since the first release and were found on a
device: changing the audio track did nothing, and no subtitle would load.
### 🐛 Fixes
- **Changing the audio track changes the audio.** Picking a different language
did nothing on Android — the menu closed, the tick moved, and the original
track kept playing, with nothing saying otherwise. When the server is
converting a film it builds that conversion around *one* audio track, so the
others are not in the stream that arrives; the app was asking the player to
select from tracks it had never been sent. It now asks the server for the
track you picked and resumes where you were. A film playing in its original
form still switches instantly, because there every track really is present.
(UR-021 → DR-258)
- **Subtitles load.** Every subtitle in the list was inert: the address the app
fetched them from was missing a segment, so each request came back "not
found", and a subtitle that never arrives is a subtitle the player cannot
offer. All of them had been failing this way since the first release — the
tests that were supposed to cover the address were checking a copy of it kept
inside the tests, not the one being requested. (UR-020 → DR-259)
- **The player's menus stop covering each other, and stay on screen.** Opening
the quality menu on top of the audio menu left both open in the same corner,
the newer hiding rows of the older and both still taking taps. Only one opens
now. They were also positioned against the icon that opened them, which sits
mid-row — so on a phone held upright a panel hung off the left edge and half
its rows could not be read or reached. (UR-020, UR-021, UR-066, UR-074 →
DR-256)
- **Podcast episodes list newest first.** Every list was sorted by name
regardless of what it contained, which for a podcast discards the running
order — and because played episodes are labelled as such by the server,
sorting by name also gathered everything already heard at the top. What order
a container's children take is now decided by what the container is.
(UR-007 → DR-257)
## v0.11.0
Video can play through the native renderer on Linux, and the machinery every
platform's playback goes through was rebuilt around one contract. Nine defects
fell out of doing it — each one a capability the code had written down as a
fact about the platform rather than asking the thing that would know.
### ✨ Changes
- **Video can decode natively on Linux, without the server re-encoding it.**
Until now every video played on the desktop was transcoded by Jellyfin to
h264 and handed to the browser engine, whatever the file actually was — so the
server burned CPU on every play, and quality was capped by that conversion.
mpv can now draw the picture directly, composited beneath the interface so the
controls, subtitles and overlays still sit on top of it. Direct play means the
original file, hardware decoding, and no server work at all. This is off by
default while it settles: set `JELLYTAU_NATIVE_VIDEO=1` to try it. The browser
path is untouched and remains what you get otherwise. (UR-080 → DR-231 …
DR-237)
- **Playback speaks one language across every player.** Linux, Android and
Windows each drove their engine through a different set of calls, and a rule
learned on one did not reach the others — which is why several of the fixes
below existed on one platform and not another. All three now go through a
single contract, and one suite of behaviours runs against every engine,
including ExoPlayer on a real device. An engine is either correct or visibly
failing. Nothing about this is visible while it works, which is the point.
(UR-081 → DR-242 … DR-247)
### 🐛 Fixes
- **Resuming a film starts where you left it, instead of at the beginning.**
Asking a player to open a file and asking it to start at a position were two
separate steps, and the second was issued before the first had finished — so
it failed, was discarded, and playback began at zero. It affected resume and
any skip on a stream the server was converting. The position is now part of
opening the file, so there is no gap for it to fall into. (DR-241)
- **Skipping works on films the server is converting.** A skip was routed by the
*shape* of the stream rather than by what the player could do with it. That
happened to be right while one particular player handled those streams and
became wrong the moment another did — after which skipping simply did nothing,
silently. Players now say what they can do and are asked. (DR-238, DR-246)
- **The play and pause button follows the player again.** The code that reacted
to pausing was never subscribed to the event it was waiting for, so the button
stayed where it was while playback did something else. (DR-239)
- **Fullscreen fills the screen.** It expanded the page rather than the window,
which was invisible while the picture was drawn inside the page and obvious as
soon as it was not. (DR-240)
- **The seek bar knows how long the film is.** A player that had not yet worked
out the duration reported zero, and zero was believed — leaving the bar with
no scale and nothing to drag against, even though the length had been known
since the library listed it. (DR-251)
- **Leaving the player stops the sound.** The stop was aimed at whichever
renderer the app believed was in charge. Enabling background audio hands over
to a different one, so afterwards the app stopped something that was no longer
playing and the film carried on as an audio track in the mini player. Closing
now stops everything, regardless of who was in charge. (DR-250)
- **Coming back from background audio no longer leaves a black screen.** The
stream that plays while the app is hidden has no fixed length, and the value a
player uses to say so is a very large negative number. Converting it crashed
the playback engine outright, which looked like a dead player with no
controls. (DR-252)
- **Android builds again.** A rule that only applied to Linux stayed attached to
code that had stopped being Linux-only, and the Android build had not compiled
since. (DR-247)
- **A quality you chose for one episode no longer caps every episode after it.**
Dropping the quality mid-episode is meant to describe that episode. When the
next one started in the background, nothing reset it — so the ceiling stayed
in force indefinitely, with nothing in the interface saying why later episodes
looked worse. (DR-254)
- **Skipping to the next item no longer starts it part-way through.** Scrubbing
near the end of a converted stream re-opens it, and the position being waited
for was not discarded if you skipped onward first — so the next item began
wherever you had dragged to in the previous one. (DR-253)
- **The player's menus no longer cover each other, or the edge of the screen.**
Audio track, quality and subtitles could all be open at once, stacked in the
same corner with the newest panel hiding rows of the one underneath, and each
one was anchored to its own icon — which sits mid-row, so on a phone in
portrait the panel hung off the left edge and half the tracks could not be
read or tapped. One menu is open at a time now (the desktop volume slider
included), it opens against the edge of the control bar clamped to the screen
it is on, and tapping anywhere else dismisses it. The row of icons wraps
instead of pushing fullscreen and close past the edge. (DR-256)
### 🧹 Under the hood
- The conformance suite can be run on its own: `bun run test:player` for the
desktop engines, `bun run test:player:android` for ExoPlayer on a connected
device. Both build a test fixture rather than carrying media in the
repository.
- [docs/native-player-verification.md](docs/native-player-verification.md)
records what to check before a release, including the exact sequences that
found two of the defects above — both of which passed every automated test.
### Known limitations
- Resume reads progress saved on the device, not from the server, so a fresh
install or a second device will not offer to resume something watched
elsewhere.
- Native video on Linux is opt-in and is not yet the default.
## v0.10.1
A single fix, for something that had been quietly overriding a choice you made.
-2
View File
@@ -33,7 +33,6 @@
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
- [Playback Backend Unification](specs/playback-backend-unification.md)
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
- [libmpv2 Migration](specs/libmpv2-migration.md)
@@ -49,7 +48,6 @@
- [Build & Release](build/build-release.md)
- [Release Checklist](release-checklist.md)
- [Native Player Verification](native-player-verification.md)
- [Desktop Packaging](build/build-desktop-packages.md)
- [Windows Build](build/build-windows.md)
- [Defect Windows](defect-windows.md)
-27
View File
@@ -49,33 +49,6 @@ sequenceDiagram
- Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
### Listing order is decided in Rust
**TRACES**: UR-007 | DR-257
A browse call names the **container** (`GetItemsOptions.parentKind`, the neutral
`MediaKind` the caller already holds) and not a sort field.
`default_listing_sort` in `repository/types.rs` turns that kind into the order:
| Container kind | Order |
|---|---|
| `channelFolder` — one podcast inside a plugin channel | `PremiereDate` descending |
| any other container | `SortName` ascending |
| none given | no `SortBy` — the server's own order stands |
Both legs of the race apply it, so the cached list does not flash in name order
before the server's arrives. An explicit `sortBy` from the caller always wins;
the default only fills the gap.
This is a domain rule, not a display preference, which is why it is not in the
frontend: the store that asks for a podcast's episodes has no business knowing
that podcasts are read newest-first. `MediaKind::ChannelFolder` exists for the
same reason — Jellyfin gives a channel container and an ordinary folder the same
item type (`ChannelFolderItem`), and while both mapped to `Folder` there was
nothing to key the rule on. The defect this prevents: every Jellypod podcast
listed alphabetically, which discarded the release order *and* clumped every
`[Played] …` episode at the top of the list.
## Search Flow (Locally Indexed)
**TRACES**: UR-065 | DR-108 … DR-111, IR-030
+2 -14
View File
@@ -17,17 +17,11 @@ row can be re-checked or disputed:
## Present since the first release
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and two months before anyone hit them.
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
for between two weeks and seven weeks short of two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
DR-265 is the clearest example of the "present since" / "reachable since" gap
this file warns about: the `!isPlaying` gate has been there since the first
commit, but nothing paused the document underneath a playing `<video>` until PiP
started working on the HTML5 path in v0.5.3. Defective for ~9 weeks, hittable
for ~2.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
@@ -40,13 +34,9 @@ for ~2.
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
### Why they took so long to surface
@@ -92,8 +82,6 @@ silently correct an out-of-range index — which is exactly why it was reported
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
| Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | **v0.11.4** | feature (the v0.5.1 fix for the same symptom) |
| PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | **v0.11.5** | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
-202
View File
@@ -1,202 +0,0 @@
# Native player — verification plan
What to check before the `MediaPlayer` contract and Linux native video reach
`master`.
This is not a generic smoke test. Every case below exists because something
specific went wrong, and most of them were found on hardware **after** the
automated suites were green. Treat the sequences as load-bearing: several
defects only appeared in a particular order of actions, and testing the same
features in a different order missed them entirely.
Companion to [release-checklist.md](release-checklist.md), which covers the
release mechanics. This covers whether the player is fit to release at all.
## What is risky about this change
- `PlayerController` now talks to a `MediaPlayer` contract instead of
`PlayerBackend`. Every engine reaches it through an adapter that did not exist
before (DR-245).
- mpv decodes video on Linux for the first time, composited under the webview
(DR-231).
- Seek strategy is driven by an ability each engine declares rather than by a
truth table (DR-246).
- Two regressions were introduced during this work and caught only on a device:
a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic
(DR-252). Both were invisible to the test suites.
The suites originally verified only engines that *behave*, which is why both
regressions passed them. That gap is now partly closed in code rather than in
this document: `UT-223` drives a deliberately hostile engine — `C.TIME_UNSET`,
NaN, infinities, negatives — through the adapter, and fails with the exact
panic that produced a black screen on a tablet. `UT-224` pins the handoff
clearing that was previously verified by listening to a device.
**Prefer moving cases out of this file and into tests.** Anything here that
could fail automatically should; a checklist depends on someone remembering to
follow it, and the two defects it was written for cost hardware time that would
have been better spent making the suites realistic. What is left below is what
genuinely needs eyes, ears, or a display — not what merely has not been
automated yet.
## 1. Automated gates
Cheap, fast, and non-negotiable. Run from the worktree.
```bash
bun run check # 0 errors, 0 warnings
bun run test # frontend
bun run test:rust # Rust
bun run format:check
bun run lint # 0 errors; warnings at or below the CI ratchet
bun run check:boundary
bun run traces:validate
bun run traces:coverage # at or above MIN_THRESHOLD
cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --features conformance -- -D warnings
```
The eslint warning count is a **ratchet**: equal to the CI limit is a pass, one
over fails the build. Going one over is how a piece of dead state was found
during this work — do not raise the limit to get past it.
## 2. Engine conformance
```bash
bun run test:player # mpv + legacy, desktop
bun run test:player:android # ExoPlayer, on a connected device
```
Expected, and each deviation is meaningful rather than noise:
| Engine | Result | If it differs |
|---|---|---|
| `MpvPlayer` | 9/9 | A real regression. Stop. |
| `LegacyPlayer` | 8/9 | The one failure is `transport_settings_round_trip`: the old trait has no mute or rate. Any *other* failure is a regression. |
| ExoPlayer (device) | 7/7 | Two cases are absent because the Kotlin player exposes no mute or rate. |
A green conformance run is **not** sufficient evidence to ship. Both regressions
introduced during this work passed conformance.
## 3. Desktop (Linux)
Run with native video on, since that is what is new:
```bash
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
```
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
- [ ] **Transcoded play** — something the server must re-encode (4K, HEVC, or an
audio codec the renderer cannot take).
- [ ] **Resume** — an item watched previously *on this install*. The prompt
appears and playback starts at the offered position, not at zero.
*(Resume is device-local — see "Known open".)*
- [ ] **Scrub** on a direct-play item; position lands and playback continues.
- [ ] **Scrub on a transcoded item.** Separate case on purpose: it takes a
different path, and it silently did nothing for months (DR-238).
- [ ] **Pause and resume** — the button follows the player. It stopped doing so
when a property was handled but never observed (DR-239).
- [ ] **Fullscreen** — the window really fills the display. Measure it if
unsure: the log prints `rendering WxH`, and a height short of the panel
means the document went fullscreen and the window did not (DR-240).
- [ ] **Exit the player** — audio stops. Listen; do not assume.
- [ ] **Audio-only playback** still works: mini player, queue, next/previous.
- [ ] Nothing in the log matches `PANIC` or `ERROR`.
## 4. Android
The tablet needs the *side-by-side* build. **Do not uninstall the release app**
to make an install succeed — see "Known open" for why the normal command is
currently wrong.
```bash
bun run android:build --device
./scripts/sync-android-sources.sh
cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
:app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
-x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
-x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
```
Confirm the package is `com.dtourolle.jellytau.debug` before installing:
```bash
aapt2 dump packagename <apk>
```
If it says `com.dtourolle.jellytau`, the suffix was lost — **stop**, re-sync and
re-assemble. Installing it would try to replace the real app.
Then, with `adb logcat` capturing:
- [ ] Play a video. Picture, sound, and controls.
- [ ] **Scrub.** The bar has a scale — a duration of `0.0` means the seek bar has
nothing to scrub against (DR-251).
- [ ] Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a
transcode in place; declaring otherwise re-opened it (DR-246).
- [ ] PiP.
- [ ] Lockscreen: controls respond and position tracks.
- [ ] **The handoff sequence, in this exact order:**
1. play a video
2. enable background audio
3. background the app — audio continues
4. foreground the app — **video returns**
5. exit the player — **everything stops**
Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
the same actions in another order finds neither.
- [ ] `grep -c 'PANIC at' <logcat>` returns 0.
## 5. Regression checks with a named cause
Each of these presented as something other than its cause, which is why they are
listed separately from the feature passes above.
| Symptom to look for | Was actually | Ref |
|---|---|---|
| Skip on a transcoded item does nothing, or jumps to zero | Seek strategy keyed on the container, not the engine | DR-238, DR-246 |
| Play/pause button does not follow the player | A property handled but never observed, so the event never arrived | DR-239 |
| Fullscreen leaves a strip of desktop | The document went fullscreen, the window did not | DR-240 |
| Resume plays from the beginning | A seek issued before the engine had a file was discarded | DR-241 |
| Scrub bar has no scale | Duration reported as `0.0` and believed | DR-251 |
| Black screen, no controls, after a background-audio round trip | A junk duration converted to a `Duration` panicked the backend | DR-252 |
| Audio still playing after leaving the player | The stop was aimed at whichever renderer bookkeeping believed was active | DR-250 |
## Known open — decide, do not discover
None of these are fixed. Each needs an explicit ship / do-not-ship call rather
than being met with surprise during testing.
- **Resume is device-local.** Progress is read from the local database and
nothing consults the server's `UserData`. A fresh install, a second device or
a reinstall offers no resume even though the server knows the position. Not a
regression — it has always been so.
- **The background-audio handoff is an unconfirmed state swap.**
`exit_background_audio` marks the video element the player again the moment it
is called, while the element has not reloaded. DR-250 makes the visible
symptom impossible; the race is intact and can still misdirect a lockscreen
command or a position read. See
[media-player-controller.md](specs/media-player-controller.md).
- 🔴 **The side-by-side debug install is broken.** `bun run android:dev`
produces an APK with the *release* application id, because the Tauri build
regenerates `gen/build.gradle.kts` after the sync drops the `.debug` suffix in.
It then fails on signatures, and its own error message advises uninstalling —
which would destroy the real app's data. **Fix this before anyone else builds
for Android.**
- **`PlayerBackend` still exists** behind `LegacyPlayer`, and the frontend still
carries some playback state. DR-248 and DR-249 are not started.
## Ship criteria
Ship when:
1. Every automated gate in §1 passes.
2. Conformance matches §2 exactly, deviations included.
3. §3 and §4 are complete, on real hardware, by a person.
4. §5 shows no symptom returning.
5. Every item in "Known open" has a recorded decision.
Do not ship on green suites alone. Both regressions introduced during this work
passed every suite and were caught by a person using the app.
+5 -82
View File
@@ -90,10 +90,6 @@ For a narrative overview of the system design, see
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
| UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed |
| UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed |
| UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
---
@@ -139,7 +135,6 @@ External system integrations and platform-specific implementations.
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
| IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed |
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
> integration requirements were written when libmpv was expected to be the single
@@ -437,43 +432,6 @@ Internal architecture, components, and application logic.
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
| DR-241 | A seek issued before MPV has a file to seek in is honoured, not dropped. `loadfile` returns as soon as the command is queued, so `time-pos` — a live property of the *loaded* file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the `FileLoaded` handler; a seek that lands normally clears any deferred one, so the newer intent wins | Player | UR-040, UR-005 | Done |
| DR-242 | The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules | Player | UR-081 | In Progress |
| DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress |
| DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done |
| DR-245 | `PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | Done |
| DR-246 | The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. `Capabilities::seeks_transcoded_in_place` is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from `is_hls` and `use_html5`. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decision | Player | UR-040, UR-081 | Done |
| DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done |
| DR-250 | Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: `exit_background_audio` marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer exists | Player | UR-040, UR-005 | Done |
| DR-251 | A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports `C.TIME_UNSET` until it resolves one and `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answered `Some(0.0)` rather than `None` — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole time | Player | UR-005, UR-040 | Done |
| DR-252 | Seconds reported by an engine are converted to a `Duration` only when finite and positive. `Duration::from_secs_f64` panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports `C.TIME_UNSET` (`Long::MIN_VALUE`, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — `/Audio/{id}/universal` is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a `Duration` by the `MediaPlayer` adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into it | Player | UR-005 | Done |
| DR-253 | A deferred seek is discarded when the file it was issued against stops being the one loading. `seek` holds a position while MPV has nothing loaded and the `FileLoaded` handler applies it (DR-241), but neither `load` nor `stop` cleared it — so scrubbing near the end of a transcoded item, which re-opens the stream, and then skipping to the next item before the reload completed applied the old position to the new item. It started wherever the previous one had been scrubbed to, silently | Player | UR-040, UR-005 | Done |
| DR-254 | Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why | Repository | UR-074 | Done |
| DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done |
| DR-256 | The video control bar opens **at most one menu at a time**, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against **its own icon button**, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge | UI | UR-020, UR-021, UR-066, UR-074 | Done |
| DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done |
| DR-258 | An audio-track change is honoured by **re-opening the stream** when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — but the native path only ever called `setAudioTrack(n)`, which indexes ExoPlayer's audio track *groups*. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: ExoPlayer held one audio track while the menu listed every track in the file, so every selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. `determine_audio_track_switch_strategy` now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer, not the UI's: the native path has no `<video>` element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning | Player | UR-021, UR-005 | Done |
| DR-259 | Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. Since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a *mock helper* duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release | Repository | UR-020 | Done |
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
| DR-263 | Autoplay crosses the **season boundary**. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled *into*: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it | Player | UR-023, UR-040 | Done |
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | Done |
| DR-265 | The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way | Player | UR-004, UR-041 | Done |
| DR-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
| DR-267 | `profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the *current* server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field | Auth | UR-082 | Proposed |
| DR-268 | A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does **not** encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide | Auth | UR-083 | Proposed |
| DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed |
| DR-270 | Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown *ordering* can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable | Auth | UR-082 | Proposed |
| DR-271 | Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it *is* the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design | Repository | UR-082 | Proposed |
| DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed |
| DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a *session* by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed |
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -487,10 +445,10 @@ Internal architecture, components, and application logic.
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
@@ -506,7 +464,7 @@ Internal architecture, components, and application logic.
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
| UR-026 | - | DR-029, DR-048, DR-050 |
@@ -523,8 +481,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
@@ -563,9 +521,6 @@ Internal architecture, components, and application logic.
| UR-078 | - | DR-218 |
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
| UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276 |
| UR-083 | - | DR-268, DR-275, DR-276 |
| UR-084 | - | DR-269 |
---
@@ -784,38 +739,7 @@ Internal architecture, components, and application logic.
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
| UT-220 | The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playing | DR-242, DR-243 | In Progress |
| UT-221 | An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800s | DR-251 | Done |
| UT-222 | The values that killed the backend are rejected rather than converted: `C.TIME_UNSET` as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survives | DR-252 | Done |
| UT-223 | The adapter survives an engine that answers badly. A `HostileBackend` reports `C.TIME_UNSET` as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend down | DR-252 | Done |
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
| UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done |
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
| UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done |
| UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | Done |
| UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done |
| UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done |
| UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done |
| UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done |
| UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done |
| UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done |
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
| UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done |
| UT-239 | An episode watched to the end is not the current episode: not when the server's Next Up still names it (the stale answer is skipped in favour of the one after it), and not offline, where it counts as watched in the furthest-watched scan | DR-264 | Done |
| UT-240 | Caching a server result mirrors its played flag locally — as synced, never invented for an item that carries no user data, and never over an unsynced local toggle | DR-264 | Done |
| UT-241 | A newly-imported album reads as one album card, not one card per song: three tracks sharing an album id collapse into a single `MusicAlbum` entry that opens the album, keeps its artwork and album artist, drops track-only detail (track number, duration, album link), and takes the position of the first of its tracks so recency order and the neighbouring movie survive | JA-016 | Done |
| UT-242 | When the server did group, its own album row wins — its overview and detail survive and the tracks it also returned add no second card for the same album | JA-016 | Done |
| UT-243 | A track that names no album has no container to collapse into and stays a track, the same way a movie does | JA-016 | Done |
| UT-244 | Recently Added over-fetches before collapsing, so folding one 14-track import together does not leave the row nearly empty | JA-016 | Done |
| UT-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done |
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
@@ -835,7 +759,6 @@ Internal architecture, components, and application logic.
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
---
-1
View File
@@ -45,7 +45,6 @@ taken by other work; each carries a ⚠️ note at the top.
| Spec | Blocked on / note |
|---|---|
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed. |
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
@@ -1,242 +0,0 @@
# Spec: Backend-owned stream selection
**Status:** Proposed
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
DR-121**, currently allocated to
[read-through-media-cache.md](read-through-media-cache.md) and not started.
Re-check `requirements.md` before allocating — the ids moved twice while this was
being written (`DR` max was 215, then 218).
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
changes what fills it, not how it looks.
**Supersedes / revises:** takes DR-121 out of
[read-through-media-cache.md](read-through-media-cache.md), which should keep
only its capture/eviction half. Unblocks
[linux-native-video-spike.md](linux-native-video-spike.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
"Streaming quality ladder" section; and
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
durable half is the layer line and the `StreamSelection` contract; phases and
acceptance criteria are disposable.
## Summary
Make Rust the single owner of *which stream to play* — direct play or transcode,
at what ceiling, over what transport — and hand every player backend a
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
`<video>`/hls.js path all become consumers of the same decision rather than three
places that re-derive it.
Nothing about how playback *looks* changes. What changes is that the frontend
stops inferring transport from a URL string, and that direct play becomes
possible at all.
## Motivation
Four concrete problems, all the same shape.
**1. The frontend sniffs transport out of the URL.**
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
```ts
const isHlsStream = currentStreamUrl.includes(".m3u8");
```
and again inline at line 2364. Rust *built* that URL and knows exactly what it
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
path, serve a progressive file, and this silently picks wrong. This is the
boundary rule in miniature — not item-type taxonomy, but the same error: a
domain fact reconstructed in the presentation layer because the wire shape did
not carry it.
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
play burns server CPU, even when the file would play untouched. This is the cost
the Linux native-video work exists to remove, and it cannot be removed without a
decision that does not currently exist anywhere in the codebase.
**3. Quality is a process-wide global.** `streaming_quality()` /
`set_streaming_quality()` in `repository/online.rs` read and write a static.
It is not per-session or per-item, so it cannot express "this 4K remux needs a
ceiling, that podcast does not", and two concurrent playbacks would share one
setting.
**4. Rust cannot say what qualities *this* media source supports.** The selector
is populated from a fixed enum rather than from what the source actually offers.
DR-121 already names this; it has not been built.
### The prior question
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
in this repo suggests **there is no ABR today**: a single rendition is requested,
no level-handling code exists anywhere in the frontend, and a quality switch is
implemented by re-opening the stream.
**Run this before sizing the adaptation work.** It needs a live server:
```
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
| grep -c EXT-X-STREAM-INF
```
`1` → there is no adaptation to preserve, and the adaptation half of this spec
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
**Everything else in this spec is worth doing either way** — the ownership
problems above are independent of the answer.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
### The line
**Rust decides *what stream*. The player decides *how to deliver it*.**
This matters most for ExoPlayer, which already does real adaptive track selection
over HLS. This spec must not reimplement that or fight it — if a multi-variant
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
same restraint applies to any future backend that gains the capability. Rust only
steps in where the player has no such ability (mpv) *and* the server actually
offers a ladder.
Borderline row, with its tie-breaker: "which media source of a multi-source item"
looks like a user choice, and its *presentation* is. The default and the
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
## Design
### The contract
One self-describing selection replaces the bare URL. Nested fields are
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
tagged so the frontend matches a tag instead of parsing a string.
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StreamSelection {
pub url: String,
pub transport: Transport,
pub playback_kind: PlaybackKind,
/// The negotiated rendition; None when direct-playing the source as-is.
pub rendition: Option<Rendition>,
/// What this media source can offer — fills the selector (DR-121).
pub available: Vec<QualityOption>,
}
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Transport { Hls, Progressive, LocalFile }
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
```
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
substring search.
### Re-negotiation
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
`StreamSelection` plus the position to resume at. The existing
`playerSetStreamQuality` response already has exactly the right shape — a tagged
`strategy` that tells the caller who reloads, with the backend handling native
itself and handing HTML5 a URL for `reloadSource`
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
invent a second mechanism.** It is the one piece of this that is already right.
Note the existing wart to preserve or fix deliberately, not accidentally:
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
comments say so.
### Phases
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
behaviour change — pure ownership move, and independently shippable.
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
3. **DR-221** `available` populated from the media source (DR-121's substance).
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
the phase that unlocks native video and removes the transcode.
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
playlist is a last resort, not a starting point.
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
contract is player-agnostic rather than HTML5-shaped.
Phases 14 stand on their own merits with no dependency on the ladder question.
## Out of scope
- Rendering, compositing, and the Linux native-video work itself. This spec
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
not contain it.
- Replacing hls.js. It stays as the HLS loader for the webview path.
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
(DR-122, DR-124, DR-125), which keeps its own spec.
- Audio. The same argument applies, but video is where the transcode cost is.
## Acceptance criteria
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
and transport comes from the tagged enum.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
no transport/kind decision was reconstructed in `src/`, since the tripwire
only catches item-type array literals.
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
coverage stays ≥ the CI ratchet.
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
started or dropped.
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
## Testing
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
(supported container direct-plays; unsupported codec transcodes; a ceiling
below the source bitrate transcodes even when the codec is fine).
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
- Frontend: adapter selection driven by `transport`, including the case a URL
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
which is the point.
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
- No test asserts a URL substring.
## TRACES
| Piece | Tag |
|---|---|
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
| Per-session ceiling | `UR-074 \| DR-220` |
| `available` from media source | `UR-079 \| DR-221, DR-121` |
| Direct-play negotiation | `UR-079 \| DR-222` |
| Adaptation, if built | `UR-079 \| DR-223` |
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
## Notes for the implementer
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
It removes a real leak and costs almost nothing.
- Do not frame any phase as "no Rust changes required" — that framing is what
produced the leak `scoped-search-boundary.md` records.
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
real traffic, never from a side-channel poller.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes. Requirement ids in particular moved twice
during the writing of this spec.
-324
View File
@@ -1,324 +0,0 @@
# Spec: MediaPlayer — one controller API, three interchangeable engines
**Status:** **Partially implemented.** DR-242 … DR-247 have shipped: the
contract, `FakePlayer` and the conformance suite, `MpvPlayer`, the standalone
runner, `LegacyPlayer`, the controller port, the capability-driven seek
strategy, and ExoPlayer conformance on a device. What is left is DR-248 (the
webview as an engine) and DR-249 (deleting `PlayerBackend` and the frontend
playback-state flags).
**Requirements:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check
`requirements.md` before allocating — ids moved several times while this was
written.
**UX spec:** n/a — no user-visible change is intended. That is the point.
**Supersedes / revises:** absorbs `determine_video_seek_strategy`
(`player/seek.rs`, DR-238) into the engines. Revises the backend half of
[playback-backend-unification.md](playback-backend-unification.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — replaces the player
state-machine section; and
[05-platform-backends.md](../architecture/05-platform-backends.md) — the engines
become implementations of a stated contract rather than three separate designs.
## Summary
Replace the `PlayerBackend` trait with a `MediaPlayer` contract that expresses
**intent** ("present this item, starting here") rather than **device operations**
("load", then "seek"). MPV, ExoPlayer and the webview element implement it; a
`FakePlayer` implements it for tests; and one conformance suite runs against
every implementation so a backend is either correct or visibly failing.
No user-visible behaviour changes. What changes is that playback logic stops
being written three times in the command layer.
## Motivation
A day of debugging Linux native video produced four defects (DR-238 … DR-241).
Every one of them traces to the same missing seam, not to mpv:
| Defect | What it looked like | What it was |
|---|---|---|
| DR-241 | "Resume is broken", "I cannot skip" | `loadfile` is async, so a seek issued straight after a load fails and was discarded. The trait has no way to say *open at a position*, so every caller does load-then-seek and each races independently. |
| DR-238 | Transcoded seeks silently did nothing | `use_html5` was doing double duty as "who renders" **and** "how do I seek", decided in the command layer by a truth table. |
| DR-239 | Play/pause control never moved | `PropertyChange { name: "pause" }` was handled but never observed. Nothing in the contract required an engine to report its own state. |
| DR-240 | Fullscreen left the picture at window size | `requestFullscreen()` moves the document; whoever owns the pixels has to be told separately. |
The shape is consistent: **the same intent implemented in several places, each
with its own timing and its own idea of the rules.** Resume worked through the
adapter (which seeks after `File loaded`) and failed through the command (which
seeks immediately). Two callers, one intent, two behaviours.
Supporting evidence for the diagnosis:
- `commands/player/mod.rs` is **3,561 lines** and is where "stop → rebuild URL →
update queue → load → seek" lives. That is playback orchestration in the IPC
layer.
- `player_play_item` needed a `#[cfg(not(target_os = "linux"))]` guard, i.e. a
platform decision in a command handler.
- The frontend carries `didStartNativePlayback`, `didStopBackendEarly`,
`hasPerformedInitialSeek`, `lastAppliedInitialPosition` — playback state in the
UI, which contradicts the one-directional rule in CLAUDE.md.
### Why an abstraction, and not more fixes
Each defect above was individually cheap to patch, and patching them is what
produced a regression: routing transcoded seeks to a reload path turned "seek
does nothing" into "seek jumps to zero", because the reload path's own seek was
broken in the same way. **Symptom fixes in this area compound.**
## The background-audio handoff is an unconfirmed state swap
Diagnosed on a device, 2026-08-23, and the likeliest explanation for "audio
keeps playing after I leave the player" — the report this whole line of work
started from.
`enter_background_audio` and `exit_background_audio` in `PlayerController` are
pure bookkeeping: they flip a boolean and set or clear a base offset. Neither
confirms that the audio stream actually opened, nor that the webview `<video>`
actually came back. `exit_background_audio`'s own doc comment says the element
"becomes the player again once it reloads" — a future event nothing waits for,
while the flag declares the swap complete the moment it is called.
The sequence that exposes it:
1. Background audio is enabled.
2. The app is backgrounded — `enter_background_audio(pos)`, audio stream opens.
3. The app is foregrounded — `exit_background_audio()` sets the flag back, so
the controller believes the video element owns playback again.
4. The player is exited *before the element has reloaded*. The stop is aimed at
an element that does not exist yet; the audio stream is still running.
5. The mini player sees a live audio session and adopts it — which is why the
symptom is a **movie appearing as an audio track**, and why it is
intermittent rather than reliable.
Duration reporting `0.0` on Android widens the window: the reload is slower and
less certain to land at the right position.
**This is the same defect class as DR-238 … DR-241: state asserted rather than
confirmed.** It is what `Phase::Opening` and `MpvPlayer`'s open generation
exist for — a handoff *is* an open in flight, and a `close` during one has to
cancel it rather than race it. The handoff is not modelled as an open at all
today; it is two booleans and an offset.
The fix therefore belongs with this contract rather than beside it: route the
handoff through `open`/`close` so the swap has a phase, and so leaving the
player during one cancels the thing that is actually playing instead of the
thing the controller believes is playing. `close_during_open_never_plays`
already states the required behaviour and passes on all four engines — the gap
is that the handoff never reaches an engine as an open.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Presenting an item at a position, in one operation | **Engine** (`MediaPlayer`) | Only the engine knows when its pipeline can accept a position. Expressing it as caller-sequenced load-then-seek exports a race the engine is the only one able to close. |
| Whether *this* stream can be seeked in place, or must be re-opened | **Engine** | A property of the engine × transport pair: hls.js seeks a VOD playlist, mpv's HLS demuxer cannot make Jellyfin transcode from a new offset. Today this is a truth table in a command handler that has to guess for engines it does not own. |
| Reporting position, phase, duration, active tracks | **Engine** | The player is the authoritative source of playback state (CLAUDE.md). An engine that does not report is not implementing the contract — DR-239 was exactly this. |
| Choosing *which* stream to open (direct play vs transcode, ceiling, transport) | **Rust, above the engine** | Domain: depends on Jellyfin's `PlaybackInfo`, codec support, quality ceiling. See [backend-owned-stream-selection.md](backend-owned-stream-selection.md). The engine is handed a `StreamSelection`; it never negotiates one. |
| Queue, autoplay, session, playback reporting | **`PlayerController`** | Policy across items. Unchanged — but it talks to one contract instead of branching per platform. |
| Which engine this platform uses | **Rust, at construction** | Already correct today; stays a single `cfg` at the composition root rather than `cfg`s scattered through command handlers. |
| Rendering surfaces, controls, fullscreen chrome | **Frontend / platform** | Presentation. The engine reports *what* is playing; it does not own the window. |
Borderline row and its tie-breaker: "should a transcoded seek re-open the
stream?" reads like domain policy. It is **engine** capability — the *decision*
is "seek to T", and how to achieve it is the engine's business. If it were
policy, every new engine would require editing a shared truth table, which is
precisely the coupling DR-238 came from.
## Design
### The contract
```rust
/// Anything that can present media: MpvPlayer, ExoPlayer, WebviewPlayer, FakePlayer.
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. `open` is where a start position is
/// *expressible*, so no caller has to sequence load-then-seek and no caller
/// can race the engine's own load. An engine that cannot start at an offset
/// natively must absorb that internally (defer until loaded, or re-open) —
/// it is the only layer that knows when it is able to.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item. Must be idempotent, and must leave the
/// engine producing no audio — DR-2xx exists because "stopped" and "silent"
/// were not the same thing.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's timeline.
///
/// The engine decides in-place vs re-open. Callers never choose.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: Volume) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of everything the UI consumes.
fn snapshot(&self) -> PlaybackSnapshot;
/// Engine capabilities, so callers can adapt without naming engines.
fn capabilities(&self) -> Capabilities;
}
```
```rust
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection, // url + transport + playback kind
pub start: Duration, // Duration::ZERO for "from the beginning"
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
pub autoplay: bool,
}
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
pub duration: Option<Duration>,
pub seekable: bool,
pub volume: Volume,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
/// `Opening` is the state today's code cannot express, and the direct cause of
/// DR-241: a seek arriving with nothing loaded had no phase to be rejected or
/// queued against, so it was simply lost.
pub enum Phase { Idle, Opening, Ready, Playing, Paused, Ended, Failed(String) }
```
Engines emit `PlayerEvent` for phase, position, track and error changes. Emitting
is part of the contract, and the conformance suite asserts it — an engine that
stays silent fails, which is what would have caught DR-239 the day it landed.
### What this deletes
- `determine_video_seek_strategy` and `VideoSeekStrategy` — replaced by
`seek()` + `capabilities()`. The command layer stops deciding how engines seek.
- The reload orchestration in `player_seek_video` — moves inside the engines that
need it.
- `#[cfg(target_os = "linux")]` branches in command handlers.
- Frontend playback-state flags, which become reads of `snapshot()`.
### IPC
No new commands. Existing ones keep their names and shapes; they become thin
delegations. `PlayerStatus` gains nothing the frontend does not already receive.
Regenerate `bindings.ts` only if `PlaybackSnapshot` is exposed directly — prefer
mapping it onto the existing `PlayerStatus` so this stays invisible at the wire.
## Testing
This is the half that makes the abstraction worth having, and it is the reason to
do it rather than keep patching.
### 1. A conformance suite, run against every engine
One set of tests, parameterised over implementations. Any `MediaPlayer` must pass
it; a new engine is "done" when it does.
```
conformance::run(&mut engine, fixture) covering:
open(start = ZERO) -> phase Ready|Playing, position ~0
open(start = 10min) -> position within tolerance of 10min, NEVER 0 [DR-241]
seek while Opening -> honoured once Ready, not discarded [DR-241]
seek on a transcoded stream -> position lands, by whatever means [DR-238]
pause / play -> phase changes AND an event is emitted [DR-239]
close -> phase Idle, silent, idempotent
close during Opening -> no playback ever starts [audio-on-exit]
volume / rate / track select -> reflected in snapshot()
```
The `open(start = 10min)` and `seek while Opening` cases are the ones that fail
on today's code. They are written first, and they are the acceptance criterion.
### 2. `FakePlayer`
A deterministic in-memory implementation with a controllable clock. Lets
`PlayerController`, autoplay, queue, sleep-timer and session logic be tested with
no mpv, no device, no network — most of which is currently only reachable through
a real engine.
### 3. Per-engine runs
| Engine | Where | Note |
|---|---|---|
| `FakePlayer` | `cargo test` | Always. |
| `MpvPlayer` | `cargo test`, Linux | libmpv is already in the builder image (the Linux build links it), so **no CI toolchain install** — see CLAUDE.md. Needs a tiny local fixture file; generate it in-test rather than committing media. |
| `ExoPlayer` | instrumented, on device | Not in the standard CI job. Run via `scripts/` on a connected device; record results in the PR. |
| `WebviewPlayer` | vitest | Against a stubbed element, as `html5Adapter` is tested today. |
An engine that cannot run in CI still has the same suite; it is just run by hand.
That is the point of writing it once.
## Migration
Strangler, not a rewrite. Each step ships independently and leaves the app working.
1. **DR-242** Define `MediaPlayer`, `OpenRequest`, `PlaybackSnapshot`, `Phase`,
`Capabilities`. No implementations. Compiles alongside `PlayerBackend`.
2. **DR-243** `FakePlayer` + the conformance suite. The suite fails against
nothing yet — it is the specification.
3. **DR-244** `MpvPlayer` implementing `MediaPlayer`, wrapping today's
`MpvBackend` internals. Make conformance pass, including `open(start)`.
4. **DR-245** `PlayerController` talks to `MediaPlayer`. `PlayerBackend` retained
behind an adapter so the other engines keep working.
5. **DR-246** Move seek strategy and reload orchestration out of
`commands/player/mod.rs` into the engines; delete `seek.rs`'s truth table.
**Shipped with a deviation.** The engine cannot own this outright:
re-negotiating a stream needs the repository, which sits *above* the engine.
So the engine *declares* `seeks_transcoded_in_place` and the caller acts on
it. That removes the defect — nobody guesses on another component's behalf,
and adding an engine no longer means editing a shared table — without
pretending an engine can reach upward. `determine_video_seek_strategy`
survives as a correctly-typed decision over declared abilities rather than
being deleted; the defect was its *input*, not its existence.
6. **DR-247** `ExoPlayerPlayer`; conformance on device.
7. **DR-248** `WebviewPlayer`; retire the adapter shim.
8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags.
Steps 13 are pure addition and risk nothing. Step 5 is where today's defect
classes actually die.
## Out of scope
- Stream selection (which URL, which quality) — that is
[backend-owned-stream-selection.md](backend-owned-stream-selection.md), and
this spec consumes its `StreamSelection` rather than duplicating it.
- Rendering surfaces and compositing.
- Any user-visible behaviour change. If one appears, it is a bug in the migration.
- Replacing hls.js or changing the transcode path.
## Acceptance criteria
- [ ] The conformance suite exists and `open(start = 10min)` fails against the
pre-migration mpv path — proving it reproduces DR-241 — then passes.
- [ ] `FakePlayer` lets at least one controller-level test run with no engine.
- [ ] `determine_video_seek_strategy` is deleted, not merely bypassed.
- [ ] No `cfg(target_os = ...)` remains in `commands/player/`.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt`, `cargo clippy -D warnings`, `bun run test:rust` pass.
- [ ] `bun run check:boundary` passes.
- [ ] `// TRACES:` on new code; `bun run traces:validate` passes; coverage stays
at or above the CI ratchet.
- [ ] Manual: resume, skip on a transcoded item, pause/play, and exit-while-playing
verified on Linux **and** Android before `PlayerBackend` is deleted.
## Notes for the implementer
- **Write the conformance suite before the second engine**, or it will encode
whatever the first engine happens to do.
- `close()` must mean *silent*. The bug that motivated this spec had `stop` being
called, reported, and audible afterwards.
- Do not let `Capabilities` grow into engine sniffing. If a caller branches on
the engine's identity, the contract is missing something — add it there.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
-405
View File
@@ -1,405 +0,0 @@
# Spec: Multi-user profiles with PIN switching
**Status:** Proposed
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
**Destination on completion:**
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
## Summary
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
same server** and switch between them in a couple of taps. Adult accounts can set
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
away. An adult who forgets their PIN signs in with their Jellyfin password
instead — there is no separate reset flow.
The feature is **opt-in and invisible until used**: one account with no PIN
behaves exactly as the app does today.
## Motivation
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
`users`), keeps a token per user in the keyring, and exposes
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
only way to change account today is `auth_logout`, which calls Jellyfin's logout
endpoint and **invalidates the token server-side**, forcing a full password login
every time. On a living-room device shared by a family that is the difference
between "switch to the kids' profile" and "find the password".
Two defects block simply exposing the existing commands, and both are the real
work of this spec:
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
controls filter *server responses*, but the cache-first read path returns local
rows before the server answers — so a child profile on a device a parent has
browsed sees the parent's titles and artwork.
2. **Download availability is answered per item, not per user.**
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
counts completed rows for an `item_id` with no user predicate, so a child's UI
marks a parent's download as available and can play it offline.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
comes from the DB. Rust returns the timestamp; the frontend decides it means
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
## Design
### Threat model — state it plainly
The PIN is a **switching gate against a member of the household**, not at-rest
protection against an attacker with the disk. Tokens stay in the keyring exactly
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
does **not** encrypt them.
This is a deliberate choice, and the rejected alternative matters enough to
record: wrapping each token with a key derived from its PIN would resist an
offline attacker, but a locked profile would then be *unable to act as itself*
no resuming its downloads after a restart, no draining its `sync_queue`, no
session polling — until someone walked past and typed four digits. On a device
that reboots nightly that is a worse product for a threat this feature does not
face. A four-digit code was never going to resist an offline attack anyway.
Consequences to document in 09-security.md rather than discover later:
- Anyone with the SQLite file and keyring access has every profile's token,
PIN or not.
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
restriction is Jellyfin's server-side parental controls, which most self-hosters
have never configured — the UI must say so when a PIN-less profile is created.
### Schema
```sql
-- Migration 024
CREATE TABLE user_pins (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
failed_count INTEGER DEFAULT 0,
locked_until TEXT, -- RFC3339; NULL when not locked out
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- What the server has actually shown to this user. NOT a maintained index:
-- written as a byproduct of the cache write path, so it cannot disagree with
-- what the server returned.
CREATE TABLE user_item_visibility (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, item_id)
);
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
-- Same, at library granularity, from each user's /UserViews.
CREATE TABLE user_libraries (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL,
PRIMARY KEY (user_id, library_id)
);
-- Downloads: one file, many claimants.
CREATE TABLE download_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, download_id)
);
```
**Migration of existing installs is not optional.** Every current cache row and
download predates the concept of a user. Migration 024 backfills
`user_item_visibility` and `download_grants` for the single existing user (and,
if somehow several `users` rows exist, for the one with `is_active = 1`).
Without the backfill an upgrading user's library goes blank.
### Cache scoping — a byproduct, not an index
The reason this is tractable: the repository **already carries the user**.
```rust
pub struct OfflineRepository {
db_service: Arc<RusqliteService>,
server_id: String,
user_id: String, // already there, already used for user_data joins
}
```
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
Every row enters the cache because *some specific user's* request returned it, and
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
— the single write choke point, called only from
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
stamps `user_item_visibility` in the same transaction as the item. There is no
reconciliation job and no way for the stamp to drift from what the server said,
because the stamp *is* the record of what the server said.
Reads join through it. 43 of the 64 `FROM items` sites live in
`repository/offline.rs`, where `self.user_id` is already in scope.
**The 21 sites outside the repository are triaged, not blanket-scoped:**
| Group | Disposition |
|-------|-------------|
| Browse/query paths returning lists to the UI | Must scope |
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
The triage result is recorded in 08-database-design.md, because "why isn't this
one scoped?" is exactly what a future change gets wrong.
**Known limit — revocation drift.** The stamp can never show *more* than the
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
visibility rows for libraries that disappeared. Offline, the cache stays
stale-permissive. This is a stated property, not a bug.
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
outside an allowlist of modules. With writes funnelled and 43 reads in one file
the allowlist is short enough to mean something — unlike `check:boundary`, which
had to pattern-match literals. It will not catch a missed join *inside* the
repository; it will catch a new query appearing in a random command file, which
is the realistic drift.
### Downloads — shared files, per-user grants
The file layout already assumes sharing: paths are content-derived
(`{base}/{series}/{S01E02 - Name}`,
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
episode already aim at one path and clobber each other. Formalising:
- A second profile requesting an already-downloaded item inserts a **grant**. No
bytes transferred; immediately available.
- `offline_is_available` joins through grants instead of counting rows per item.
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
drops the caller's grant and unlinks the file **only when the last grant goes**.
It currently deletes unconditionally, which under sharing would yank a file from
under another profile.
- Budget is naturally shared: the file is counted once. Eviction picks files with
no recent access across *any* grant.
- A grant is not an entitlement. On unlock while online, grants for items the
profile can no longer see are dropped, alongside the visibility re-derivation.
### Device ID
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
installation, sent as `DeviceId` on every request
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
identify a *session* — the Dashboard → Devices row, and the target the
remote-control feature casts to.
**Decision pending an empirical test** (see Open questions). Shipping default is a
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
member a distinct device entry so playback history attributes cleanly and the
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
changes.
### Switch orchestration
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
server-side; a switch must leave the outgoing profile able to come back with one
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
unit-testable without a player or a server:
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
a straggler would report the outgoing profile's episode against the incoming one).
2. Drain or park `sync_queue` for the outgoing user.
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
4. Destroy the repository handle.
5. Flip `users.is_active`.
6. Build the new repository, restart the poller, re-derive visibility and grants
if online.
7. Emit `profile-switched`.
Two hazards, both already documented in CLAUDE.md and both reached from a new
direction here: never call blocking APIs from player event callbacks, and never
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
at once.
### Lock state vs. playback
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
timeout, off by default) flips only the first:
- Audio keeps playing and keeps reporting as the profile that started it.
- The lockscreen / MediaSession keeps full transport control over the **existing
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
needs no changes at all.
- The locked UI refuses anything reaching past the current queue: browsing,
search, new playback, downloads, settings, switching profile without the PIN.
Two rules keep it coherent:
- **Never re-lock while something is playing.** The idle timer starts when
playback stops, not when the UI goes quiet. This removes almost all of the
conflict on its own.
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
profile leaves everything running.
The timer lives in Rust beside the player state machine: it needs authoritative
playback state, and a frontend timer dies with the webview on Android.
### Startup
The picker appears only when the last-used profile has a PIN, **or** more than one
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
does today. One account, no PIN → the user never sees any of this.
### Commands
Names match the Rust fns; top-level params auto-convert to camelCase.
```rust
profiles_list() -> Vec<Profile>
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
profiles_remove(user_id: String, forget_downloads: bool)
```
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub user_id: String,
pub username: String,
pub avatar_tag: Option<String>,
pub unlock_method: UnlockMethod,
pub last_used_at: Option<String>,
pub is_active: bool,
}
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UnlockMethod { None, Pin }
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UnlockOutcome {
Ok { user_id: String },
WrongPin { attempts_remaining: u32 },
LockedOut { until: String },
NeedsPassword,
}
```
Event: `profile-switched` (kebab-case), payload `{ userId }`.
`profiles_add` takes no server URL — it authenticates against the *current*
server. That is the same-server constraint, enforced in Rust rather than by
omitting a form field.
## Out of scope
- Multiple servers. The schema already supports it (`users.server_id`); only the
flow is constrained. Not a schema change to undo later.
- Per-profile content restriction. That is Jellyfin's, server-side.
- Profile avatars uploaded locally — use the server's `avatar_tag`.
- Biometric unlock.
- Idle re-lock is specified above but ships **off by default** and last.
## Acceptance criteria
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
- [ ] A second profile can be added with a password, against the current server only.
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
- [ ] "Use password instead" signs in and offers to set a new PIN.
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
- [ ] `bindings.ts` regenerated.
## Testing
**Rust**
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
service restart; a cleared PIN removes the row.
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
teardown *ordering*, no player or server needed. This is the part that would
otherwise only ever be hand-tested.
- Visibility: `save_to_cache` stamps; a second user's read of the same item
returns nothing; migration backfill populates the existing user.
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
cancel of the last grant unlinks it.
- Same-server: `profiles_add` against a different URL is rejected.
**Frontend**
- Picker renders from `profiles_list` with no unlock-method inference of its own.
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
never compares a PIN or counts an attempt locally.
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
differing library permissions on a real server; CI has neither. The state-machine
extraction above is what keeps the risky half testable. The rest is a documented
manual pass in the release checklist.
## TRACES
| Piece | Tag |
|-------|-----|
| `profiles_*` commands | `UR-082 \| DR-267` |
| PIN hash + lockout | `UR-083 \| DR-268` |
| Password fallback | `UR-084 \| DR-269` |
| Switch orchestration | `UR-082 \| DR-270` |
| Visibility stamp/filter | `UR-082 \| DR-271` |
| Download grants | `UR-082 \| IR-034, DR-272` |
| Per-profile device ID | `UR-082 \| DR-273` |
| Startup target | `UR-082 \| DR-274` |
| Idle re-lock | `UR-083 \| DR-275` |
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
## Open questions
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
first user's token?** Two minutes with two accounts: log in as A, log in as B
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
the per-profile device ID is a preference or a requirement.
2. Should removing a profile default to deleting its exclusive downloads, or
keeping them? Spec currently makes it an explicit flag.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
server-side invalidation is the whole reason it is unsuitable.
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
would strand every existing token.
+6743 -7731
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.5",
"version": "0.10.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
@@ -53,9 +53,7 @@
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
"release:notes": "bun run scripts/release-notes.ts",
"test:player": "./scripts/test-player-conformance.sh",
"test:player:android": "./scripts/test-player-conformance.sh android"
"release:notes": "bun run scripts/release-notes.ts"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
+1 -1
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.11.5
pkgver=0.10.1
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
-38
View File
@@ -128,44 +128,6 @@ else
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
fi
# The applicationId the APK actually carries — not the one build.gradle.kts asks
# for. `tauri android build` rewrites the debug `buildTypes` block in the
# generated gradle file to inject its keepDebugSymbols entries, and that rewrite
# used to drop `applicationIdSuffix` with it, silently producing a debug APK
# under the release applicationId. Installing that over a real release build
# fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE, whose only obvious remedy is
# uninstalling the release app and losing its data — so this fails the build
# instead. The suffix now lives outside the rewritten block (see
# src-tauri/android/app/build.gradle.kts); this checks that it survived.
assert_application_id() {
local variant="$1" expected="$2"
local metadata="src-tauri/gen/android/app/build/outputs/apk/universal/$variant/output-metadata.json"
[ -f "$metadata" ] || return 0
local actual
actual=$(sed -n 's/.*"applicationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -1)
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
echo ""
echo "❌ APK applicationId is '$actual', expected '$expected'."
echo " A build meant for the side-by-side slot came out under the"
echo " release applicationId; installing it would collide with a real"
echo " install. Check that the applicationIdSuffix at the bottom of"
echo " src-tauri/android/app/build.gradle.kts survived into"
echo " src-tauri/gen/android/app/build.gradle.kts."
exit 1
fi
}
if [ "$BUILD_TYPE" = "debug" ]; then
assert_application_id debug "com.dtourolle.jellytau.debug"
elif [ "$SIDE_BY_SIDE" = "1" ]; then
assert_application_id release "com.dtourolle.jellytau.debug"
else
assert_application_id release "com.dtourolle.jellytau"
fi
echo ""
echo "✅ APK build complete!"
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
-13
View File
@@ -36,19 +36,6 @@ if [ -d "$TEST_SOURCE_DIR" ]; then
echo " Copied unit tests: src/test"
fi
# Instrumented tests (src/androidTest). These need a device: they drive
# ExoPlayer, which requires an Android Context and a Looper and therefore
# cannot run from the desktop conformance suite. Run with
# `./gradlew :app:connectedDebugAndroidTest` from gen/android.
ANDROID_TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/androidTest/java/com/dtourolle/jellytau"
ANDROID_TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/androidTest/java/com/dtourolle/jellytau"
if [ -d "$ANDROID_TEST_SOURCE_DIR" ]; then
rm -rf "$ANDROID_TEST_TARGET_DIR"
mkdir -p "$ANDROID_TEST_TARGET_DIR"
cp -r "$ANDROID_TEST_SOURCE_DIR"/. "$ANDROID_TEST_TARGET_DIR/"
echo " Copied instrumented tests: src/androidTest"
fi
# Copy individual Kotlin files (like VideoOverlayManager.kt)
for kt_file in "$SOURCE_DIR"/*.kt; do
if [ -f "$kt_file" ]; then
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Run the MediaPlayer conformance suite.
#
# See docs/specs/media-player-controller.md. One set of behaviours, run against
# every engine — so a wrapper is verified without building or launching the app.
#
# ./scripts/test-player-conformance.sh desktop engines (mpv, legacy)
# ./scripts/test-player-conformance.sh android ExoPlayer, on a connected device
#
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET="${1:-desktop}"
run_desktop() {
local fixture="${TMPDIR:-/tmp}/jellytau-conformance-1200s.mp4"
if [ ! -f "$fixture" ]; then
# Generated, not committed: the repo carries no media, and the duration
# is exact — the seek assertions depend on it.
echo "Generating a 20-minute fixture at $fixture"
ffmpeg -y -loglevel error \
-f lavfi -i "testsrc2=size=640x360:rate=25" \
-f lavfi -i "sine=frequency=440" \
-t 1200 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 50 \
-c:a aac -shortest "$fixture"
fi
cd "$PROJECT_ROOT/src-tauri"
local status=0
for engine in mpv legacy; do
echo
cargo run --quiet --features conformance --bin player-conformance -- \
"$fixture" "$engine" || status=1
done
return $status
}
run_android() {
if ! adb get-state >/dev/null 2>&1; then
echo "No device. Connect one and enable USB debugging." >&2
exit 1
fi
"$PROJECT_ROOT/scripts/sync-android-sources.sh" >/dev/null
cd "$PROJECT_ROOT/src-tauri/gen/android"
# `-x rustBuild...` because raw gradle drives the Rust build through Tauri's
# android-studio-script, which expects a dev-server address file that only
# exists under `tauri android dev`. The native library already in
# app/src/main/jniLibs is what the test process loads.
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
./gradlew :app:connectedUniversalDebugAndroidTest \
-x :app:rustBuildUniversalDebug --console=plain
}
case "$TARGET" in
desktop) run_desktop ;;
android) run_android ;;
*) echo "usage: $0 [desktop|android]" >&2; exit 2 ;;
esac
+1 -49
View File
@@ -176,18 +176,6 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "ascii"
version = "1.1.0"
@@ -372,12 +360,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -408,15 +390,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -940,7 +913,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -2209,10 +2181,9 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.5"
version = "0.10.1"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"base64 0.22.1",
"chrono",
@@ -2229,7 +2200,6 @@ dependencies = [
"libmpv-sys",
"log",
"ndk-context",
"password-hash",
"rand 0.8.7",
"reqwest 0.12.28",
"rusqlite",
@@ -3095,17 +3065,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -4325,12 +4284,6 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -5640,7 +5593,6 @@ dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
+2 -27
View File
@@ -1,10 +1,6 @@
[package]
name = "jellytau"
# The app. Named explicitly because the crate also builds
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.11.5"
version = "0.10.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -43,7 +39,7 @@ tauri-plugin-opener = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "v5"] }
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7"
@@ -64,12 +60,6 @@ aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
# Profile PIN hashing (DR-268). A switching gate against a member of the
# household, not at-rest protection -- but a hash is the right primitive for a
# gate, and Argon2id costs nothing extra over a weaker one.
argon2 = "0.5"
password-hash = { version = "0.5", features = ["alloc", "rand_core"] }
log = "0.4"
env_logger = "0.11"
@@ -151,18 +141,3 @@ ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
[features]
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
# to non-test builds, so an engine that cannot run in-process — ExoPlayer on a
# device — is driven by the same cases as the ones that can, rather than by a
# second checklist that drifts.
conformance = []
# A standalone runner for the conformance suite. Deliberately a separate binary:
# it links libmpv and nothing else, so a wrapper can be verified without building
# or launching the app.
[[bin]]
name = "player-conformance"
path = "src/bin/player_conformance.rs"
required-features = ["conformance"]
-37
View File
@@ -92,43 +92,6 @@ That means:
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
(defaults to debug).
### Getting a test APK out of CI
`.gitea/workflows/build-test-apk.yml` builds one from **any branch, on demand**
— run it from Gitea's Actions tab (`workflow_dispatch`) against the ref you want.
It is not a release: nothing is tagged, published, or signed with the real key.
Two variants, both installing into the `com.dtourolle.jellytau.debug` slot:
| Variant | What it is | When |
|---------|-----------|------|
| `side-by-side-release` (default) | R8-minified, exactly what ships, signed with the debug keystore | Almost always — a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before |
| `debug` | Unminified | When you need readable stack traces |
There is deliberately **no push trigger**: the runner has one slot shared with
two other projects, so building on every feature-branch commit would starve
them. The APK lands as the `jellytau-test-apk` artifact (7-day retention), named
for the branch and short SHA, with its size and SHA256 in the run summary.
#### Sending a build to an outside tester
Gitea **artifacts require an account** with read access to download, so an
artifact is no use to someone outside the project. Tick **`publish`** on the
dispatch and the APK is also attached to a **pre-release**, whose assets are a
plain public URL on a public repo — no account, no MR, no merge to `master`.
Two things make that safe to do from a feature branch:
- The tag is `test-<branch>`, **not** `v*`. Only `v*` triggers
`build-release.yml`, so nothing else reacts to it.
- It cannot reach existing users. The desktop updater reads a static
`latest.json` from the `updater` branch, not the release list, so a
pre-release published this way is invisible to anyone without the link.
Re-dispatching for the same branch replaces the APK on the existing
pre-release rather than piling up one release per attempt. Delete the release
when testing is over.
### Key Files
Player-related Kotlin files:
+1 -35
View File
@@ -46,9 +46,6 @@ android {
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
// Required to run the on-device conformance suite
// (src/androidTest). See docs/specs/media-player-controller.md.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
@@ -73,9 +70,7 @@ android {
// fully-qualified class names Rust looks up over JNI, the manifest
// <service> entry and the R8 keep rules are all unaffected. The
// FileProvider authority is already ${applicationId}-relative.
//
// The suffix itself is applied AFTER this block -- see the bottom of
// this file. It cannot live here.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
@@ -129,30 +124,6 @@ android {
}
}
// The debug applicationId suffix, applied from OUTSIDE the `buildTypes` block.
//
// `tauri android build` rewrites the `getByName("debug")` block in the *generated*
// copy of this file (src-tauri/gen/android/app/build.gradle.kts) to inject its
// `jniLibs.keepDebugSymbols` entries -- you can see the damage in the generated
// file, where `packaging {` ends up with the first injected line welded onto it.
// That rewrite drops `applicationIdSuffix` and nothing else: `versionNameSuffix`
// and the manifest placeholders beside it survive. It happens after
// sync-android-sources.sh has copied this file into place and before Gradle
// configures, so no amount of syncing can beat it.
//
// The result was a debug APK whose applicationId was plain
// `com.dtourolle.jellytau`, colliding with a real release install:
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, with the only obvious way out being to
// uninstall the release app and lose its data. The `sideBySideRelease` suffix in
// the *release* build type is untouched by the same rewrite, which is why that
// path kept working and this one did not.
//
// A top-level statement is not inside the block the rewriter looks for, so it
// survives. scripts/build-android.sh asserts the built applicationId afterwards,
// so a future CLI that reaches further fails the build instead of shipping a
// colliding APK.
android.buildTypes.getByName("debug").applicationIdSuffix = ".debug"
rust {
rootDirRel = "../../../"
}
@@ -168,10 +139,6 @@ dependencies {
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-common:1.5.0")
// SubtitleView. ExoPlayer delivers cues to a listener and draws none of them
// itself: without a view to hand them to, a selected subtitle track renders
// nowhere. See JellyTauPlayer.onCues. (DR-260)
implementation("androidx.media3:media3-ui:1.5.0")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
@@ -180,7 +147,6 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
}
apply(from = "tauri.build.gradle.kts")
@@ -1,252 +0,0 @@
package com.dtourolle.jellytau.player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.abs
/**
* The MediaPlayer conformance cases, run against ExoPlayer on a real device.
*
* The desktop suite (src-tauri/src/player/conformance.rs) cannot reach here:
* ExoPlayer needs an Android Context and a Looper, so it only exists inside an
* app process. These are the same behaviours, asserted against the engine
* itself rather than the Rust wrapper — the layer below the contract.
*
* The fixture is generated rather than committed: a long silent WAV written to
* the cache directory at setup. No binary in the repo, no `adb push` step, and
* the duration is exact, which matters for the seek assertions.
*
* Run: ./gradlew :app:connectedDebugAndroidTest (from src-tauri/gen/android)
*
* TRACES: UR-081 | DR-247
*/
@RunWith(AndroidJUnit4::class)
class PlayerConformanceTest {
private lateinit var player: JellyTauPlayer
private lateinit var mediaUrl: String
/** Long enough to seek well past any buffer. */
private val fixtureSeconds = 1200
/**
* ExoPlayer lands on the nearest sync sample, and a `prepare` is not
* instantaneous. Generous on purpose: a tight bound here produces a test
* that fails on a slow device and teaches people to re-run until green.
*/
private val toleranceSeconds = 10.0
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
JellyTauPlayer.initialize(context)
player = JellyTauPlayer.getInstance()
val fixture = File(context.cacheDir, "conformance-$fixtureSeconds.wav")
if (!fixture.exists() || fixture.length() < 1024) {
writeSilentWav(fixture, fixtureSeconds)
}
mediaUrl = fixture.toURI().toString()
}
@After
fun tearDown() {
onMain { player.stop() }
// Leave nothing playing for the next case.
Thread.sleep(200)
}
// ---------------------------------------------------------------- cases
@Test
fun opensFromTheBeginning() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
assertNear(0.0, position(), "playback should start at the beginning")
assertTrue("duration should be known once loaded", duration() > 0)
}
/**
* DR-241. Opening at a position starts *there*, not at zero.
*
* `load(url, mediaId)` has no way to express a start position, so every
* caller loads and then seeks — and a seek issued against a player that is
* still preparing is the window resume was lost in on the desktop side.
* This is the same defect on ExoPlayer.
*/
@Test
fun opensAtAStartPosition() {
val start = 600.0
onMain { player.load(mediaUrl, "conformance", start) }
awaitLoaded()
assertTrue(
"opened at ${start}s but playback began at ${position()}s - " +
"the start position was dropped",
position() > 1.0
)
assertNear(start, position(), "start position")
}
/** DR-241. A seek issued while still preparing is honoured, not lost. */
@Test
fun seekWhileOpeningIsHonoured() {
val target = 300.0
onMain {
player.load(mediaUrl, "conformance")
// Deliberately before the player is ready: this is the race,
// expressed on purpose rather than stumbled into.
player.seek(target)
}
awaitLoaded()
assertNear(target, position(), "seek issued while opening")
}
@Test
fun seeksAfterOpen() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
val target = 420.0
onMain { player.seek(target) }
awaitPosition(target)
assertNear(target, position(), "seek after open")
}
/** DR-239. Pause and play are observable, not merely accepted. */
@Test
fun pauseAndPlayAreObservable() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.pause() }
awaitPlaying(false)
assertFalse("a paused player must not report playing", isPlaying())
onMain { player.play() }
awaitPlaying(true)
assertTrue("a resumed player must report playing", isPlaying())
}
/** `stop()` releases the item, is silent, and can be called twice. */
@Test
fun closeIsSilentAndIdempotent() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.stop() }
awaitPlaying(false)
assertFalse("a stopped player must not report playing", isPlaying())
onMain { player.stop() }
assertFalse("stop must be idempotent", isPlaying())
}
/**
* An open cancelled by stop must not come back to life.
*
* The shape of "audio kept playing after leaving the player": a prepare
* still in flight completed after the stop, with nothing left to tell it
* not to.
*/
@Test
fun closeDuringOpenNeverPlays() {
onMain {
player.load(mediaUrl, "conformance")
player.stop()
}
Thread.sleep(2000)
assertFalse(
"a load cancelled by stop must not start playing",
isPlaying()
)
}
// -------------------------------------------------------------- helpers
private fun onMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
}
private fun position(): Double = readOnMain { player.getPosition() }
private fun duration(): Double = readOnMain { player.getDuration() }
private fun isPlaying(): Boolean = readOnMain { player.getExoPlayer().isPlaying }
private fun <T> readOnMain(block: () -> T): T {
var out: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { out = block() }
@Suppress("UNCHECKED_CAST")
return out as T
}
/** Poll a state the player publishes rather than sleeping a fixed time. */
private fun await(what: String, timeoutMs: Long = 15_000, predicate: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (predicate()) return
Thread.sleep(50)
}
throw AssertionError("timed out waiting for $what")
}
private fun awaitLoaded() {
await("the player to report a duration") { duration() > 0 }
// One more beat so a start position or a deferred seek has landed.
Thread.sleep(500)
}
private fun awaitPosition(target: Double) =
await("position to reach ${target}s") { abs(position() - target) <= toleranceSeconds }
private fun awaitPlaying(expected: Boolean) =
await("isPlaying == $expected", 5_000) { isPlaying() == expected }
private fun assertNear(expected: Double, actual: Double, what: String) {
assertTrue(
"$what: expected ~${expected}s, got ${actual}s (tolerance ${toleranceSeconds}s)",
abs(actual - expected) <= toleranceSeconds
)
}
/**
* Write a silent 8 kHz mono 16-bit WAV of `seconds` length.
*
* Synthesised rather than committed so the repo carries no media, and so
* the duration is exact — the seek assertions depend on it.
*/
private fun writeSilentWav(file: File, seconds: Int) {
val sampleRate = 8000
val dataBytes = sampleRate * 2 * seconds
file.outputStream().buffered().use { out ->
fun le32(v: Int) = out.write(
byteArrayOf(
(v and 0xff).toByte(),
((v shr 8) and 0xff).toByte(),
((v shr 16) and 0xff).toByte(),
((v shr 24) and 0xff).toByte()
)
)
fun le16(v: Int) =
out.write(byteArrayOf((v and 0xff).toByte(), ((v shr 8) and 0xff).toByte()))
out.write("RIFF".toByteArray()); le32(36 + dataBytes); out.write("WAVE".toByteArray())
out.write("fmt ".toByteArray()); le32(16); le16(1); le16(1)
le32(sampleRate); le32(sampleRate * 2); le16(2); le16(16)
out.write("data".toByteArray()); le32(dataBytes)
val chunk = ByteArray(sampleRate * 2) // one second of silence
repeat(seconds) { out.write(chunk) }
}
}
}
@@ -15,9 +15,6 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
object VideoOverlayManager {
private var attachedSurfaceView: TextureView? = null
/** The cue view attached alongside it, removed by the same teardown. */
private var attachedSubtitleView: androidx.media3.ui.SubtitleView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null
@@ -59,25 +56,6 @@ object VideoOverlayManager {
contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView
// Subtitles go directly above the video and still below the WebView:
// visible through the transparent page, and under the app's own
// controls rather than over them. Index 1 is what makes that
// sandwich — the same reason the video is pinned to index 0.
// TRACES: UR-020, UR-003 | DR-260
player.getSubtitleView()?.let { subtitles ->
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
contentView.addView(
subtitles,
1,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
attachedSubtitleView = subtitles
android.util.Log.d("VideoOverlayManager", "Subtitle view attached above the video")
}
// Re-fit the video whenever the content view's bounds change (e.g. on
// device rotation) so the video is letterboxed to fit instead of being
// stretched/cropped by the MATCH_PARENT surface.
@@ -123,10 +101,6 @@ object VideoOverlayManager {
fun detachVideoSurface() {
try {
removeLayoutListener()
attachedSubtitleView?.let { subtitles ->
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
attachedSubtitleView = null
}
attachedSurfaceView?.let { surfaceView ->
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
attachedSurfaceView = null
@@ -233,23 +233,6 @@ class JellyTauPlayer(private val appContext: Context) {
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/**
* Draws subtitle cues over the picture.
*
* ExoPlayer decodes subtitles and *delivers* them to a listener; it draws
* none of them itself. A `PlayerView` would supply this view, but native
* video here is a bare TextureView the WebView composites over, so nothing
* was holding the cues and a selected subtitle track rendered nowhere. That
* gap was invisible for as long as every subtitle URL 404ed (DR-259) — with
* no text track to select, there was never a cue to drop.
*
* Sized and positioned to the video rect rather than the screen, so cues sit
* inside the picture rather than in a letterbox bar.
*
* TRACES: UR-020, UR-003 | DR-260
*/
private var subtitleView: androidx.media3.ui.SubtitleView? = null
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -556,19 +539,6 @@ class JellyTauPlayer(private val appContext: Context) {
}
}
/**
* Hand each cue group to the view that draws it.
*
* Fires with an empty list when subtitles are turned off or the
* track has nothing to show at this moment, which is what clears the
* previous cue — so this is the whole of both showing and hiding.
*
* TRACES: UR-020 | DR-260
*/
override fun onCues(cueGroup: androidx.media3.common.text.CueGroup) {
subtitleView?.setCues(cueGroup.cues)
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
@@ -586,31 +556,11 @@ class JellyTauPlayer(private val appContext: Context) {
* @param mediaId The unique ID for this media item
*/
fun load(url: String, mediaId: String) {
load(url, mediaId, 0.0)
}
/**
* Load [url] and begin at [startPositionSeconds].
*
* The start position is handed to ExoPlayer with the media item, not seeked
* to afterwards. `prepare()` is asynchronous, so a seek issued straight
* after a load targets a player that is still preparing: ExoPlayer clamps it
* back to zero and the item plays from the beginning. That is what made
* resume and transcoded skip start over, and it is why callers must never
* express a start position as load-then-seek.
*
* TRACES: UR-081, UR-005 | DR-241, DR-247
*/
fun load(url: String, mediaId: String, startPositionSeconds: Double) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
if (startPositionSeconds > 0.0) {
exoPlayer.setMediaItem(mediaItem, (startPositionSeconds * 1000).toLong())
} else {
exoPlayer.setMediaItem(mediaItem)
}
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
@@ -1324,85 +1274,9 @@ class JellyTauPlayer(private val appContext: Context) {
}
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
}
if (subtitleView == null) {
subtitleView = androidx.media3.ui.SubtitleView(appContext).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Text over the picture, not a black bar across it. (DR-261)
setStyle(captionStyle())
setUserDefaultTextSize()
}
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
}
return videoView!!.hashCode()
}
/**
* The caption style to draw cues in: the viewer's own, with the background
* taken out.
*
* `setUserDefaultStyle()` reads Android's captioning preferences and falls
* back to media3's `DEFAULT` when the viewer has set none — and that
* default is white on **opaque black**, which is what put a black box under
* every line, wide enough to sit across the picture.
*
* Dropping the box is not the same as replacing the style. A viewer who has
* configured captions in accessibility settings has said something specific
* about colour, typeface and edges, and overriding all of that to get a
* transparent background would be answering a question they did not ask. So
* their style is kept and only the two colours that paint a box —
* background and window — are cleared.
*
* With no box the text supplies its own contrast or it is unreadable over a
* bright scene, so a style that asked for no edge gets a black outline. One
* that already specifies an edge keeps it: that viewer has already said how
* they want their captions separated from the picture.
*
* TRACES: UR-020 | DR-261
*/
private fun captionStyle(): androidx.media3.ui.CaptionStyleCompat {
val base = try {
val captioning = appContext.getSystemService(Context.CAPTIONING_SERVICE)
as? android.view.accessibility.CaptioningManager
if (captioning != null && captioning.isEnabled) {
androidx.media3.ui.CaptionStyleCompat.createFromCaptionStyle(captioning.userStyle)
} else {
androidx.media3.ui.CaptionStyleCompat.DEFAULT
}
} catch (e: Exception) {
// A captioning service that refuses to answer is not a reason to
// draw nothing; fall back to the same default media3 would use.
android.util.Log.w("JellyTauPlayer", "Captioning preferences unavailable", e)
androidx.media3.ui.CaptionStyleCompat.DEFAULT
}
val needsOwnEdge = base.edgeType == androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_NONE
return androidx.media3.ui.CaptionStyleCompat(
base.foregroundColor,
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
if (needsOwnEdge) {
androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_OUTLINE
} else {
base.edgeType
},
if (needsOwnEdge) android.graphics.Color.BLACK else base.edgeColor,
base.typeface
)
}
/**
* The view that draws subtitle cues, for VideoOverlayManager to attach
* directly above the video and below the WebView. Null before the first
* video load.
*/
fun getSubtitleView(): androidx.media3.ui.SubtitleView? {
return subtitleView
}
/**
* Get the video view instance (for VideoOverlayManager).
* Returns null if none has been created yet.
@@ -1519,20 +1393,6 @@ class JellyTauPlayer(private val appContext: Context) {
lp.height = targetH
view.layoutParams = lp
view.requestLayout()
// Cues belong to the picture, not to the screen: matching the
// letterboxed rect keeps them off the black bars and moves them
// with the video on rotation. (DR-260)
subtitleView?.let { subs ->
val slp = subs.layoutParams
if (slp is FrameLayout.LayoutParams) {
slp.gravity = android.view.Gravity.CENTER
}
slp.width = targetW
slp.height = targetH
subs.layoutParams = slp
subs.requestLayout()
}
android.util.Log.d(
"JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
@@ -1562,10 +1422,6 @@ class JellyTauPlayer(private val appContext: Context) {
exoPlayer.clearVideoSurface()
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
videoView = null
// Released with the surface it belonged to; detachVideoSurface
// removes it from the hierarchy, and keeping the reference would
// leave the next video's cues going to an orphaned view.
subtitleView = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
}
}
-37
View File
@@ -1,37 +0,0 @@
//! Thin entry point. The suite lives in the library so the binary needs no
//! access to the player internals — one exported function rather than a public
//! module tree.
//!
//! player-conformance <media-file> [mpv|legacy]
//!
//! `legacy` drives the old `PlayerBackend` through the same cases, so the
//! difference between the two designs is demonstrated on one engine and one
//! file rather than argued.
//!
//! TRACES: UR-081 | DR-244, DR-245
use std::process::ExitCode;
use jellytau_lib::conformance_runner::{run_engine, Engine};
fn main() -> ExitCode {
let mut args = std::env::args().skip(1);
let Some(url) = args.next() else {
eprintln!("usage: player-conformance <media-file-or-url> [mpv|legacy]");
return ExitCode::from(2);
};
let engine = match args.next().as_deref() {
None | Some("mpv") => Engine::Mpv,
Some("legacy") => Engine::Legacy,
Some(other) => {
eprintln!("unknown engine {other:?} - expected mpv or legacy");
return ExitCode::from(2);
}
};
if run_engine(&url, engine) == 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
-33
View File
@@ -30,39 +30,6 @@ pub async fn auth_initialize(
// Try to restore session from storage
log::info!("[AuthManager] Restoring session from storage...");
// A PIN-protected profile is not restored automatically. Restoring it would
// hand the app a working token before anybody entered the code, leaving the
// picker as decoration over a session that was already live — the gate has
// to be on the session itself, not on which screen is shown. The frontend
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
//
// TRACES: UR-083 | DR-268, DR-274
{
let db_service = {
let db = database.0.lock().map_err(|e| e.to_string())?;
std::sync::Arc::new(db.service())
};
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
&*db_service,
crate::storage::db_service::Query::new(
"SELECT u.id FROM users u
JOIN user_pins p ON p.user_id = u.id
WHERE u.is_active = 1",
),
|row| row.get(0),
)
.await
.unwrap_or(None);
if let Some(user_id) = locked {
log::info!(
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
user_id
);
return Ok(None);
}
}
// Use the existing storage_get_active_session function
let active_session =
match crate::commands::storage::storage_get_active_session(database, credentials).await {
-2
View File
@@ -15,7 +15,6 @@ pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod playlist;
pub mod profiles;
pub mod repository;
pub mod sessions;
pub mod storage;
@@ -36,7 +35,6 @@ pub use playback_mode::*;
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use profiles::*;
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
+82 -203
View File
@@ -25,9 +25,8 @@ use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{
determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
};
use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType},
@@ -726,30 +725,18 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// Who gets the stream depends on who is going to *render* it, which is a
// runtime question, not a platform constant.
//
// Historically Linux video was always the webview's (`use_html5_element`),
// so handing the file to MPV as well would only have started a redundant
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
// and a queue-only path here. With mpv drawing the picture that inverts:
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
//
// TRACES: UR-080 | DR-231, DR-235
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
if renders_natively {
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
@@ -1182,13 +1169,6 @@ pub async fn player_stop(
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send stop command to remote session - clone client before await
let client = {
@@ -1451,7 +1431,7 @@ pub async fn player_seek_video(
// Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local) = {
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1467,34 +1447,31 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")?
.to_string();
// Neither the URL nor the item's transport is read here any more. The
// strategy turns on whether the *engine* can seek a transcode in place,
// which it declares for itself — so the container the stream happens to
// arrive in stopped being a proxy for anything (DR-246).
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
(current_item.needs_transcoding, jellyfin_id, is_local_file)
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here
// Whether a transcode can be seeked in place is asked of the engine that is
// rendering, not guessed from the URL's shape or from who is rendering.
// TRACES: UR-040, UR-079 | DR-238, DR-246
let seeks_transcoded_in_place = {
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
// The transport comes from the backend's own decision, not from searching
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
// queued without one fall back to `needs_transcoding`, which is exact:
// every transcode this app requests is HLS (DR-140).
//
// TRACES: UR-004, UR-079 | DR-225, DR-230
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
@@ -1596,37 +1573,18 @@ pub async fn player_seek_video(
}
}
/// Switch audio track.
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
/// Note: Frontend should handle saving series preferences after this command succeeds
///
/// What decides the route is **whether the stream in front of the engine
/// carries the requested track at all** — see
/// [`determine_audio_track_switch_strategy`]:
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
/// change audio track, so the stream is re-opened at the chosen
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
/// `position`; a native backend (ExoPlayer) switches in place by track-group
/// index. libmpv implements neither — it is the audio-only backend here and
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
/// which is why IR-019 is met by these two paths rather than by MPV.
///
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
/// the reloaded element back to `position`.
/// - A native backend playing a **direct play** holds the source file with
/// every track in it, so ExoPlayer selects in place by track-group index.
/// - A native backend playing a **transcode** does not. Jellyfin builds a
/// transcode around one `AudioStreamIndex`, so the alternate tracks are not
/// in the stream; the switch has to re-open it, which this command does
/// itself and resumes at `current_position`.
///
/// That last case is a bug fix, and it was the common case on Android: any
/// source whose default audio codec the device cannot decode is transcoded, so
/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
/// file. The old code called `setAudioTrack(n)` regardless, which indexes
/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
/// audio track index` and dropped the request — the default track just kept
/// playing, with nothing in the UI saying so.
///
/// libmpv implements neither selection nor reload here — it is the audio-only
/// backend and leaves `PlayerBackend::set_audio_track` at its
/// `not_implemented()` default, which is why IR-019 is met by these paths
/// rather than by MPV.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
@@ -1646,128 +1604,56 @@ pub async fn player_switch_audio_track(
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
// Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it.
// Locks are dropped at the end of this block so none is held across an
// await.
let (jellyfin_item_id, needs_transcoding, engine_position) = {
let controller = player.0.lock().await;
let engine_position = controller.absolute_position();
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
if use_html5 {
// HTML5 backend needs stream reload
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
let current_item = queue.current().ok_or("No item currently playing")?;
// Get current item to find Jellyfin ID
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
(
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string(),
current_item.needs_transcoding,
engine_position,
)
};
.to_string()
};
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — and `position`
// below tells the frontend where to seek the reloaded element back to.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
needs_transcoding, use_html5, strategy
);
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
// A direct play: the engine holds the source file, every track included.
Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position: current_position.unwrap_or(0.0),
})
} else {
// Native backend (Android ExoPlayer) - use array index
let controller = player.0.lock().await;
controller
.set_audio_track(array_index)
.map_err(|e| e.to_string())?;
return Ok(AudioTrackSwitchResponse::Native { success: true });
}
// Both reload strategies need a stream built around the chosen track.
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
// The caller's position if it has one, the engine's otherwise. The native
// path has no `<video>` element to read, so it sends none — and defaulting
// that to zero re-opened the stream at the start of the film.
let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue
// entry at the new URL, load, then seek back to where the viewer
// was. Nothing is left for the frontend to do.
let new_url = selection.url.clone();
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
}
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url) {
return Err("Failed to update stream URL in queue".to_string());
}
}
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let updated_item = queue
.current()
.ok_or("No current item after URL update")?
.clone();
drop(queue);
controller
.load_and_play(&updated_item)
.map_err(|e| e.to_string())?;
controller.seek(position).map_err(|e| e.to_string())?;
}
info!(
"[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
stream_index, position
);
Ok(AudioTrackSwitchResponse::Native { success: true })
}
// Handled above, before the stream was negotiated.
AudioTrackSwitchStrategy::BackendSelectInPlace => {
Ok(AudioTrackSwitchResponse::Native { success: true })
}
Ok(AudioTrackSwitchResponse::Native { success: true })
}
}
@@ -2200,9 +2086,7 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
supports_native_video: cfg!(target_os = "android"),
})
}
@@ -2211,11 +2095,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if crate::player::native_video::enabled() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
-544
View File
@@ -1,544 +0,0 @@
//! Profile commands: who can use this device, and how they get in.
//!
//! The rule that shapes this whole module: **switching is not logging out**.
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
//! which invalidates the token server-side — so a switch built on it would make
//! every switch back cost a password, which is the problem this feature exists
//! to solve. Nothing here calls it.
//!
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
use std::sync::Arc;
use log::{info, warn};
use tauri::{Emitter, State};
use crate::commands::sessions::SessionPollerWrapper;
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
use crate::profiles::pin::{self, PinDecision, PinState};
use crate::profiles::switch::{plan, SwitchStep};
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// Settings key for "ask who's watching on start".
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
let database = db.0.lock().map_err(|e| e.to_string())?;
Ok(Arc::new(database.service()))
}
/// The server this device is signed in to, as `(server_id, server_url)`.
///
/// Every profile operation is scoped to it — this is where the same-server
/// constraint is actually enforced, rather than by omitting a URL field from a
/// form.
///
/// The fallback to the `servers` table is not a convenience. When the last-used
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
/// session, so at startup there is no in-memory session to read — and the picker
/// still has to know which server's profiles to list. Reading it from storage is
/// what lets the PIN gate a real thing rather than just a screen.
async fn current_server(
db: &Arc<RusqliteService>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(String, String), String> {
if let Some(session) = auth_manager.0.get_session().await {
return Ok((session.server_id, session.server_url));
}
db.query_optional(
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "No server connected".to_string())
}
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
);
db.query_optional(query, |row| row.get::<_, String>(0))
.await
.ok()
.flatten()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// List the accounts this device knows for the current server.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_list(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<Vec<Profile>, String> {
let svc = service(&db)?;
let (server_id, _) = current_server(&svc, &auth_manager).await?;
store::list_profiles(&svc, &server_id).await
}
/// Whether startup should resume an account or ask who is watching.
///
/// The decision is backend state, so the frontend asks rather than computes it.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_startup_target(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<StartupTarget, String> {
let svc = service(&db)?;
let (server_id, _) = match current_server(&svc, &auth_manager).await {
Ok(pair) => pair,
// Nothing signed in yet: the picker doubles as first-run login.
Err(_) => return Ok(StartupTarget::Picker),
};
let profiles = store::list_profiles(&svc, &server_id).await?;
Ok(startup_target(&profiles, ask_on_start(&svc).await))
}
/// Read the "ask who's watching on start" setting.
///
/// Separate from [`profiles_startup_target`] on purpose: the target can be
/// `Picker` for reasons that have nothing to do with this setting — a
/// PIN-protected last profile always asks — so deriving the toggle's position
/// from it would show the user a switch that does not describe what it controls.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
let svc = service(&db)?;
Ok(ask_on_start(&svc).await)
}
/// Turn "ask who's watching on start" on or off.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_ask_on_start(
db: State<'_, DatabaseWrapper>,
enabled: bool,
) -> Result<(), String> {
let svc = service(&db)?;
let query = Query::with_params(
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(ASK_ON_START_KEY.to_string()),
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
],
);
svc.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Enter a profile, with its PIN if it has one.
///
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
/// invent a lock the backend does not have, and cannot skip one it does.
///
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
pin_code: Option<String>,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
// Same-server constraint, checked at the point of use rather than trusted
// from the caller.
let (server_id, _) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
let candidate = pin_code.unwrap_or_default();
let matches = pin::verify_pin(&candidate, &hash);
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
store::save_pin_state(&svc, &user_id, &next_state).await?;
match decision {
PinDecision::Reject { attempts_remaining } => {
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
}
PinDecision::Locked { until } => {
return Ok(UnlockOutcome::LockedOut {
until: until.to_rfc3339(),
})
}
PinDecision::Accept => {}
}
}
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// Enter a profile with its Jellyfin password, for someone who has forgotten
/// their PIN.
///
/// There is deliberately no reset token and no recovery secret: the account's
/// own password is already the authority over it, and a second credential
/// guarding the same thing would only be a weaker one. A successful password
/// entry also clears the lockout, which is what makes a forgotten PIN a
/// detour rather than a dead end.
///
/// TRACES: UR-084 | DR-269
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock_with_password(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
password: String,
device_id: String,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
let result = auth_manager
.0
.login(&server_url, &profile.username, &password, &device_id)
.await?;
if result.user.id != user_id {
return Err("Signed in as a different account".to_string());
}
save_token(&creds, &user_id, &result.access_token)?;
// The password got in, so the PIN counters have served their purpose.
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// Add another account from the **current** server to this device.
///
/// Takes no server URL. That is the same-server constraint expressed as a
/// signature rather than as form validation: there is no way to ask this command
/// for an account somewhere else.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_add(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
username: String,
password: String,
pin_code: Option<String>,
device_id: String,
) -> Result<Profile, String> {
if let Some(code) = &pin_code {
pin::validate_pin(code)?;
}
let svc = service(&db)?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
let result = auth_manager
.0
.login(&server_url, &username, &password, &device_id)
.await?;
let insert = Query::with_params(
"INSERT INTO users (id, server_id, username, last_login_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
server_id = excluded.server_id,
username = excluded.username,
last_login_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(result.user.id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(result.user.name.clone()),
],
);
svc.execute(insert).await.map_err(|e| e.to_string())?;
save_token(&creds, &result.user.id, &result.access_token)?;
if let Some(code) = pin_code {
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &result.user.id, &hash).await?;
}
info!("[Profiles] Added profile {}", result.user.name);
store::get_profile(&svc, &result.user.id)
.await?
.ok_or_else(|| "Profile vanished after being added".to_string())
}
/// Set, change, or clear a profile's PIN.
///
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
/// None`) does too — otherwise the lock could be removed by whoever is standing
/// in front of the unlocked device, which is exactly who it exists to stop.
///
/// TRACES: UR-083 | DR-268
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_pin(
db: State<'_, DatabaseWrapper>,
user_id: String,
current_pin: Option<String>,
new_pin: Option<String>,
) -> Result<(), String> {
let svc = service(&db)?;
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
let provided = current_pin.unwrap_or_default();
if !pin::verify_pin(&provided, &hash) {
return Err("Current PIN is incorrect".to_string());
}
}
match new_pin {
Some(code) => {
pin::validate_pin(&code)?;
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &user_id, &hash).await
}
None => store::clear_pin(&svc, &user_id).await,
}
}
/// Forget a profile on this device.
///
/// Does not call Jellyfin's logout endpoint: removing an account from the family
/// TV should not sign that person out on their phone. The stored token is
/// deleted locally, which is the part that actually belongs to this device.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_remove(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
user_id: String,
) -> Result<(), String> {
let svc = service(&db)?;
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
return Err("Switch to another profile before removing this one".to_string());
}
{
let store = creds.0.lock().map_err(|e| e.to_string())?;
if let Err(e) = store.delete_token(&user_id) {
warn!("[Profiles] Could not delete stored token: {}", e);
}
}
store::remove_profile(&svc, &user_id).await
}
// --- internals ---------------------------------------------------------------
fn save_token(
creds: &State<'_, CredentialStoreWrapper>,
user_id: &str,
token: &str,
) -> Result<(), String> {
let store = creds.0.lock().map_err(|e| e.to_string())?;
store
.save_token(user_id, token)
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
db.query_optional(
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|row| row.get::<_, String>(0),
)
.await
.ok()
.flatten()
}
/// Run a switch plan.
///
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
/// written out here, because the ordering is the invariant worth testing and an
/// end-to-end switch needs two real accounts on a real server to exercise.
///
/// One step is deliberately not executed here: `BuildRepository`. Repository
/// handles are created by the frontend (`repository_create`) because building
/// one needs the token and URL it already assembles at login, so the
/// `profile-switched` event is the signal to do it. What stays in Rust is the
/// part that matters — that the old handle is destroyed *before* the active user
/// flips, so nothing can write under the wrong id in between.
///
/// TRACES: UR-082 | DR-270
async fn execute_switch(
app: &tauri::AppHandle,
db: &Arc<RusqliteService>,
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: &State<'_, SessionPollerWrapper>,
from: Option<&str>,
to: &str,
) -> Result<(), String> {
let online = true;
let steps = plan(from, to, online);
for step in steps {
match step {
SwitchStep::StopPlayback => {
// The queue cannot outlive its owner: a report landing after the
// flip would attribute one account's viewing to another.
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
warn!("[Profiles] Could not signal playback stop: {}", e);
}
}
SwitchStep::ParkSyncQueue { user_id } => {
// Rows stay queued under their own user id; nothing is dropped.
// Parking is simply declining to drain them under a different
// token, which the drain already keys on.
info!("[Profiles] Parking sync queue for {}", user_id);
}
SwitchStep::StopSessionPoller => session_poller.0.stop(),
SwitchStep::ClearLockscreenMetadata => {
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
}
}
SwitchStep::DestroyRepository => {
let manager = &repository_manager.0;
for handle in manager.handles() {
manager.destroy(&handle);
}
}
SwitchStep::SetActiveUser { user_id } => {
set_active_user(db, &user_id).await?;
}
SwitchStep::BuildRepository { .. } => {
// Owned by the frontend; see the doc comment above.
}
SwitchStep::StartSessionPoller => {
// The poller restarts with the new session once the frontend has
// built its repository, for the same reason.
}
SwitchStep::RefreshVisibility { user_id } => {
info!("[Profiles] Visibility refresh queued for {}", user_id);
}
SwitchStep::EmitSwitched { user_id } => {
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
/// Make the newly-active profile the session the rest of the backend acts as.
///
/// The token lives in the credential store, which the frontend cannot read, so
/// the swap has to happen here — the frontend then rebuilds its repository
/// handle from the session it can now read back. This mirrors what
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
/// deliberately reuses the same storage path rather than a second one that could
/// drift from it.
///
/// TRACES: UR-082 | DR-270
async fn adopt_session(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(), String> {
let active = super::storage::storage_get_active_session(db, creds)
.await?
.ok_or_else(|| "Profile has no stored session".to_string())?;
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
auth_manager
.0
.set_session(Some(crate::auth::Session {
user_id: active.user_id,
username: active.username,
server_id: active.server_id,
server_url: normalized_url,
server_name: active.server_name,
access_token: active.access_token,
verified: false,
needs_reauth: false,
}))
.await;
Ok(())
}
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
db.execute(Query::new("UPDATE users SET is_active = 0"))
.await
.map_err(|e| e.to_string())?;
db.execute(Query::with_params(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
))
.await
.map_err(|e| e.to_string())?;
Ok(())
}
-171
View File
@@ -1,171 +0,0 @@
//! Runs the `MediaPlayer` conformance suite against a real engine.
//!
//! A separate binary on purpose: it links libmpv and nothing else, so a wrapper
//! can be verified without building or launching the app — which is what made
//! the previous round of playback debugging so slow. Every failure here is a
//! wrapper bug, with no UI, no webview and no server in the way.
//!
//! cargo run --features conformance --bin player-conformance -- <media-file>
//!
//! Audio and video are routed to null, so it is safe on a headless runner and
//! does not claim the speakers.
//!
//! TRACES: UR-081 | DR-244
use std::time::{Duration, Instant};
use crate::player::conformance::Harness;
use crate::player::legacy_player::LegacyPlayer;
use crate::player::media::MediaItem;
use crate::player::media_player::{MediaPlayer, OpenRequest, Phase};
use crate::player::mpv_backend::MpvBackend;
use crate::player::mpv_player::{MpvPlayer, Output};
use crate::repository::stream_selection::StreamSelection;
struct EngineHarness<P: MediaPlayer> {
player: P,
url: String,
}
impl<P: MediaPlayer> Harness for EngineHarness<P> {
type Player = P;
fn player(&mut self) -> &mut P {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file(self.url.clone());
let media = MediaItem::sample("conformance", &self.url);
OpenRequest::new(media, selection).starting_at(start)
}
/// Wait for mpv to leave `Opening`.
///
/// Polling a phase the engine publishes, not a fixed sleep: a suite whose
/// result depends on how fast the machine is will eventually be ignored.
fn settle(&mut self) {
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if self.player.snapshot().phase != Phase::Opening {
// Let the deferred seek land and one position tick arrive.
std::thread::sleep(Duration::from_millis(300));
return;
}
std::thread::sleep(Duration::from_millis(25));
}
eprintln!(" ! settle timed out - engine stayed in Opening");
}
/// mpv is on a null audio device here, so silence cannot be observed.
/// Reporting `None` skips those assertions rather than passing them
/// vacuously — an assertion that cannot fail is worse than an absent one.
fn audible(&mut self) -> Option<bool> {
None
}
/// Keyframe granularity: mpv lands on the nearest one, not on the request.
fn seek_tolerance(&self) -> Duration {
Duration::from_secs(10)
}
/// Poll until the decoder reports the new position, rather than assuming a
/// seek is visible the instant it is accepted.
fn await_seek(&mut self, target: Duration) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let pos = self.player.snapshot().position;
if pos.abs_diff(target) <= self.seek_tolerance() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
macro_rules! run {
($failed:ident, $url:expr, $make:expr, $case:path) => {{
let name = stringify!($case).rsplit("::").next().unwrap();
print!(" {name:.<52}");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut h = EngineHarness {
player: $make,
url: $url.to_string(),
};
$case(&mut h);
// Leave nothing playing behind for the next case.
let _ = h.player.close();
}));
match result {
Ok(()) => println!(" ok"),
Err(_) => {
println!(" FAILED");
$failed += 1;
}
}
}};
}
/// Which engine to interrogate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Engine {
/// The `MediaPlayer` implementation.
Mpv,
/// The old `PlayerBackend`, driven through `LegacyPlayer`.
///
/// Present so the difference between the two designs can be *demonstrated*
/// on the same engine and the same media, rather than argued.
Legacy,
}
/// Run every conformance case against `engine`. Returns the failure count.
pub fn run_engine(url: &str, engine: Engine) -> u32 {
println!("MediaPlayer conformance - {engine:?}");
println!("media: {url}\n");
let mut failed = 0u32;
use crate::player::conformance as c;
macro_rules! all_cases {
($make:expr) => {
run!(failed, url, $make, c::opens_from_the_beginning);
run!(failed, url, $make, c::opens_at_a_start_position);
run!(failed, url, $make, c::seek_while_opening_is_honoured);
run!(failed, url, $make, c::seek_while_opening_overrides_start);
run!(failed, url, $make, c::seeks_after_open);
run!(failed, url, $make, c::pause_and_play_are_observable);
run!(failed, url, $make, c::close_is_silent_and_idempotent);
run!(failed, url, $make, c::close_during_open_never_plays);
run!(failed, url, $make, c::transport_settings_round_trip);
};
}
match engine {
Engine::Mpv => {
all_cases!(MpvPlayer::new(Output::Null).expect("could not create mpv"));
}
Engine::Legacy => {
all_cases!(LegacyPlayer::new(
MpvBackend::new(
None,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
)
.expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::mpv(),
));
}
}
if failed == 0 {
println!("\nall cases passed");
} else {
println!("\n{failed} case(s) failed");
}
failed
}
/// Default entry point: the new engine.
pub fn run(url: &str) -> u32 {
run_engine(url, Engine::Mpv)
}
+2 -9
View File
@@ -72,7 +72,7 @@ pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::ChannelFolder
MediaKind::Folder
} else {
MediaKind::ChannelItem
}
@@ -142,18 +142,11 @@ mod tests {
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
/// A channel container is not an ordinary folder. Jellyfin gives both the
/// same item type, but only the channel one holds plugin content whose
/// natural order is by release date — a podcast, for instance. Collapsing
/// it into `Folder` left the repository with no way to tell the two apart,
/// so every podcast listed alphabetically.
///
/// TRACES: UR-007 | DR-257 | UT-230
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::ChannelFolder
MediaKind::Folder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
-8
View File
@@ -48,14 +48,6 @@ pub enum MediaKind {
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A *container* inside a channel — a Jellyfin `ChannelFolderItem` that is
/// itself a folder, e.g. one podcast within a podcast channel. Distinct
/// from `Folder` because its children are plugin content with an order of
/// their own (newest episode first), which a folder's name order silently
/// overrode.
///
/// TRACES: UR-007 | DR-257
ChannelFolder,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
+1 -5
View File
@@ -53,11 +53,7 @@ fn kind_rank(kind: MediaKind) -> u8 {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::ChannelFolder
| MediaKind::Folder => 1,
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
+50 -107
View File
@@ -2,10 +2,6 @@
mod android_context;
mod auth;
mod commands;
/// The MediaPlayer conformance suite, exposed for the `player-conformance`
/// binary. One entry point rather than a public player module tree.
#[cfg(feature = "conformance")]
pub mod conformance_runner;
mod connectivity;
mod credentials;
mod domain;
@@ -15,7 +11,6 @@ mod media_server;
mod playback_mode;
mod playback_reporting;
mod player;
mod profiles;
mod repository;
mod session_poller;
pub mod settings;
@@ -195,15 +190,6 @@ use commands::{
playlist_move_item,
playlist_remove_items,
playlist_rename,
profiles_add,
profiles_get_ask_on_start,
profiles_list,
profiles_remove,
profiles_set_ask_on_start,
profiles_set_pin,
profiles_startup_target,
profiles_unlock,
profiles_unlock_with_password,
// Remote session control commands
remote_play_on_session,
remote_send_command,
@@ -748,27 +734,6 @@ fn create_player_backend(
/// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler.
/// What the engine built for this platform can do.
///
/// Declared per engine, not per category. ExoPlayer speaks HLS and can seek a
/// server-side transcode in place; mpv cannot, because its HLS demuxer will not
/// make the server produce segments from a new offset. Grouping them as "native
/// engines" gets that backwards — being native is not the property that
/// matters, speaking HLS is — and treating a category as a proxy for an ability
/// is exactly the inference DR-246 removed.
///
/// TRACES: UR-081 | DR-246
fn engine_capabilities() -> crate::player::media_player::Capabilities {
#[cfg(target_os = "android")]
{
crate::player::media_player::Capabilities::exoplayer()
}
#[cfg(not(target_os = "android"))]
{
crate::player::media_player::Capabilities::mpv()
}
}
fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw,
@@ -1046,15 +1011,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
playlist_rename,
playlist_get_items,
playlist_add_items,
profiles_add,
profiles_get_ask_on_start,
profiles_list,
profiles_remove,
profiles_set_ask_on_start,
profiles_set_pin,
profiles_startup_target,
profiles_unlock,
profiles_unlock_with_password,
playlist_remove_items,
playlist_move_item,
// Diagnostics commands
@@ -1254,6 +1210,55 @@ pub fn run() {
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if std::env::var("JELLYTAU_NATIVE_VIDEO").as_deref() == Ok("1") {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface; the app will abort on the first click until \
the widget-tree shape is solved (DR-231)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => match crate::player::video_surface::attach(&vbox) {
Ok(_surface) => {
info!("[INIT] Native video surface attached");
}
Err(e) => log::warn!("[INIT] Native video surface unavailable: {e}"),
},
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// In-app update, desktop only.
//
@@ -1394,70 +1399,8 @@ pub fn run() {
playback_reporter.clone(),
position_throttler.clone(),
);
// Attached *after* the backend exists: the mpv handle is registered
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
let handle = crate::player::mpv_backend::registered_handle();
if crate::player::video_surface::attach(&vbox, handle) {
info!("[INIT] Native video surface attached");
} else {
log::warn!("[INIT] Native video surface unavailable");
}
}
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// Every engine reaches the controller through the one contract.
// `LegacyPlayer` carries the not-yet-ported ones across unchanged,
// so this port swaps a seam rather than four implementations.
// TRACES: UR-081 | DR-245
let player_controller = PlayerController::new(
Box::new(crate::player::LegacyPlayer::new(
backend,
engine_capabilities(),
)),
backend,
playback_reporter.clone(),
position_throttler.clone(),
);
-50
View File
@@ -249,56 +249,6 @@ impl PlayerBackend for NullBackend {
}
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
/// Forward the trait through a box.
///
/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
/// without this the boxed engine built at the composition root cannot be handed
/// to anything generic over the trait — `LegacyPlayer` in particular.
impl PlayerBackend for Box<dyn PlayerBackend> {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
(**self).load(media)
}
fn play(&mut self) -> Result<(), PlayerError> {
(**self).play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
(**self).pause()
}
fn stop(&mut self) -> Result<(), PlayerError> {
(**self).stop()
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
(**self).seek(position)
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
(**self).set_volume(volume)
}
fn position(&self) -> f64 {
(**self).position()
}
fn duration(&self) -> Option<f64> {
(**self).duration()
}
fn state(&self) -> PlayerState {
(**self).state()
}
fn volume(&self) -> f32 {
(**self).volume()
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
(**self).set_audio_settings(settings)
}
fn audio_settings(&self) -> AudioSettings {
(**self).audio_settings()
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
(**self).set_audio_track(stream_index)
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
(**self).set_subtitle_track(stream_index)
}
}
#[cfg(test)]
mod tests {
use super::*;
-289
View File
@@ -1,289 +0,0 @@
//! The conformance suite every [`MediaPlayer`] must pass.
//!
//! One set of behaviours, run against every engine: `FakePlayer` and `MpvPlayer`
//! in `cargo test`, `ExoPlayerPlayer` instrumented on a device, `WebviewPlayer`
//! in vitest. A new engine is finished when it passes this.
//!
//! Written *before* the second engine on purpose. A suite written afterwards
//! encodes whatever the first engine happened to do, which is how three separate
//! playback implementations drifted apart in the first place.
//!
//! Each case names the defect it exists to prevent. Two of them —
//! [`opens_at_a_start_position`] and [`seek_while_opening_is_honoured`] — fail
//! against the pre-migration mpv path, which is what makes them a reproduction
//! of DR-241 rather than a restatement of it.
//!
//! Engines differ in *when* an open completes, so the suite drives that through
//! a [`Harness`] rather than sleeping: the fake completes on demand, mpv waits
//! for its `FileLoaded` event, ExoPlayer for `STATE_READY`.
//!
//! Available to `cargo test` and, behind the `conformance` feature, to the
//! `player-conformance` binary — so an engine that cannot run in-process
//! (ExoPlayer on a device) is driven by exactly the same cases rather than by a
//! second, drifting checklist.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::media_player::{MediaPlayer, OpenRequest, Phase};
/// How the suite drives one engine.
pub trait Harness {
type Player: MediaPlayer;
fn player(&mut self) -> &mut Self::Player;
/// A request this engine can actually open, at `start`.
fn request(&self, start: Duration) -> OpenRequest;
/// Block until an in-flight `open` has finished (or failed).
///
/// The fake completes on demand; a real engine waits for its own readiness
/// event. Never a sleep — a timing-dependent suite is worse than none.
fn settle(&mut self);
/// Whether the engine is producing audio. Engines that cannot answer may
/// return `None`, which skips the silence assertions rather than passing
/// them vacuously.
fn audible(&mut self) -> Option<bool>;
/// How far a landed position may differ from the one asked for. Keyframe
/// granularity makes exactness the wrong bar for a real decoder.
fn seek_tolerance(&self) -> Duration {
Duration::from_secs(5)
}
/// Wait for a completed seek to be visible in `snapshot()`.
///
/// Engines differ in when that happens: one may record the target the
/// moment it accepts the seek, another may not report it until the decoder
/// has actually moved. Asserting immediately therefore passes on the first
/// and races on the second — which is precisely how this suite produced a
/// failure that came and went with machine load rather than with the code.
///
/// Default is a no-op, for engines whose snapshot is synchronous.
fn await_seek(&mut self, _target: Duration) {}
}
fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
let delta = actual.abs_diff(expected);
assert!(
delta <= tolerance,
"{what}: expected ~{expected:?}, got {actual:?} (tolerance {tolerance:?})"
);
}
/// Opening at zero reaches a usable state and starts near the beginning.
pub fn opens_from_the_beginning<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert!(
matches!(s.phase, Phase::Playing | Phase::Ready),
"after open the engine should hold media, phase was {:?}",
s.phase
);
assert_near(
s.position,
Duration::ZERO,
h.seek_tolerance(),
"start of item",
);
}
/// **DR-241.** Opening at a position starts *there*, not at zero.
///
/// The whole reason `OpenRequest` carries `start`. Under the previous contract a
/// caller had to `load()` then `seek()`, and because `loadfile` is asynchronous
/// the seek was issued against a player with nothing loaded, failed, and was
/// discarded — so resume and transcoded skip both played from the beginning.
pub fn opens_at_a_start_position<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert_ne!(
s.position,
Duration::ZERO,
"opened at {start:?} but playback began at zero - the start position was dropped"
);
assert_near(s.position, start, h.seek_tolerance(), "start position");
}
/// **DR-241.** A seek issued while opening is honoured, not lost.
///
/// The engine owns this window; no caller can avoid it, because a caller cannot
/// see when the pipeline becomes ready.
pub fn seek_while_opening_is_honoured<H: Harness>(h: &mut H) {
let target = Duration::from_secs(300);
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
// Deliberately before settle(): this is the race, expressed on purpose.
h.player().seek(target).expect("seek during open failed");
h.settle();
let s = h.player().snapshot();
assert_near(
s.position,
target,
h.seek_tolerance(),
"seek issued while opening",
);
}
/// A later intent wins: the seek replaces the start position it overtook.
pub fn seek_while_opening_overrides_start<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let target = Duration::from_secs(120);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.player().seek(target).expect("seek during open failed");
h.settle();
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek should override the start position it overtook",
);
}
/// Seeking a settled item lands where asked.
pub fn seeks_after_open<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek after open",
);
}
/// **DR-239.** Pause and play are reflected in the engine's own state.
///
/// An engine that changes nothing observable is indistinguishable from one that
/// ignored the call — which is exactly how a handler for mpv's `pause` property
/// sat unreachable while the UI waited for an event that never came.
pub fn pause_and_play_are_observable<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().pause().expect("pause failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Paused,
"pause must be visible in the snapshot"
);
if let Some(audible) = h.audible() {
assert!(!audible, "a paused engine must be silent");
}
h.player().play().expect("play failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Playing,
"play must be visible in the snapshot"
);
}
/// `close()` reaches Idle, is silent, and can be called twice.
pub fn close_is_silent_and_idempotent<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().close().expect("close failed");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
if let Some(audible) = h.audible() {
assert!(!audible, "a closed engine must be silent");
}
h.player().close().expect("close must be idempotent");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
}
/// Closing during an open must not let playback start afterwards.
///
/// The shape of the "audio keeps playing after leaving the player" report: an
/// open still in flight completed after the stop, and nothing was left to tell
/// it not to.
pub fn close_during_open_never_plays<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.player().close().expect("close during open failed");
h.settle();
let s = h.player().snapshot();
assert!(
!s.phase.is_active(),
"an open cancelled by close must not start playing, phase was {:?}",
s.phase
);
if let Some(audible) = h.audible() {
assert!(!audible, "an engine closed during open must be silent");
}
}
/// Volume, mute and rate round-trip through the snapshot.
pub fn transport_settings_round_trip<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().set_volume(0.25).expect("set_volume failed");
h.player().set_muted(true).expect("set_muted failed");
h.player().set_rate(1.5).expect("set_rate failed");
let s = h.player().snapshot();
assert!((s.volume - 0.25).abs() < 0.01, "volume did not round-trip");
assert!(s.muted, "mute did not round-trip");
assert!((s.rate - 1.5).abs() < 0.01, "rate did not round-trip");
}
/// Run every case against one engine.
///
/// Each case gets a fresh harness, because a suite whose cases depend on each
/// other's leftovers is one that hides state bugs instead of finding them.
#[macro_export]
macro_rules! media_player_conformance {
($name:ident, $make:expr) => {
mod $name {
use super::*;
use $crate::player::conformance as c;
macro_rules! case {
($case:ident) => {
#[test]
fn $case() {
let mut h = $make;
c::$case(&mut h);
}
};
}
case!(opens_from_the_beginning);
case!(opens_at_a_start_position);
case!(seek_while_opening_is_honoured);
case!(seek_while_opening_overrides_start);
case!(seeks_after_open);
case!(pause_and_play_are_observable);
case!(close_is_silent_and_idempotent);
case!(close_during_open_never_plays);
case!(transport_settings_round_trip);
}
};
}
-232
View File
@@ -1,232 +0,0 @@
//! A deterministic in-memory [`MediaPlayer`], for tests.
//!
//! Two jobs:
//!
//! 1. Give the conformance suite something that is correct by construction, so a
//! failure there means the *suite* is wrong rather than an engine.
//! 2. Let everything above the engine — controller, queue, autoplay, sleep
//! timer, session — be tested with no mpv, no device and no network. Most of
//! that logic is currently only reachable through a real engine, which is why
//! so little of it is covered.
//!
//! It models the one behaviour that matters most: **opening is not
//! instantaneous**. `open()` lands in [`Phase::Opening`] and stays there until
//! [`FakePlayer::complete_open`] is called, so a test can put a `seek` into that
//! window on purpose. That is the window DR-241 lived in.
//!
//! TRACES: UR-081 | DR-243
// `tick` and `fail_open` are for tests not yet written — the controller-level
// ones DR-245 unlocks. Remove this allow once those exist.
#![allow(dead_code)]
use std::time::Duration;
use super::backend::PlayerError;
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
#[derive(Debug, Clone, PartialEq)]
pub enum FakeEvent {
Opened { url: String, start: Duration },
Played,
Paused,
Closed,
Sought(Duration),
}
pub struct FakePlayer {
snapshot: PlaybackSnapshot,
/// Set while `Opening`; applied when the open completes.
pending_start: Duration,
/// A seek that arrived while opening. Honoured on completion, never dropped.
deferred_seek: Option<Duration>,
autoplay: bool,
duration: Duration,
/// Every call, in order — so tests can assert what an engine was *asked* to
/// do, not only where it ended up.
pub log: Vec<FakeEvent>,
/// Whether audio is being produced. `close()` must clear it; the bug that
/// motivated all this had a "stopped" player that was still audible.
pub audible: bool,
pub capabilities: Capabilities,
}
impl Default for FakePlayer {
fn default() -> Self {
Self::new()
}
}
impl FakePlayer {
pub fn new() -> Self {
Self {
snapshot: PlaybackSnapshot::default(),
pending_start: Duration::ZERO,
deferred_seek: None,
autoplay: true,
duration: Duration::from_secs(3600),
log: Vec::new(),
audible: false,
capabilities: Capabilities {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
// The fake honours a seek in any phase, so it can claim this.
seeks_transcoded_in_place: true,
},
}
}
/// The item this fake will report once opened.
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
/// Finish an in-flight `open`, as a real engine's "file loaded" would.
///
/// Applies the requested start position, then any seek that arrived while
/// opening — the later intent wins.
pub fn complete_open(&mut self) {
if self.snapshot.phase != Phase::Opening {
return;
}
self.snapshot.duration = Some(self.duration);
self.snapshot.seekable = true;
self.snapshot.position = self.deferred_seek.take().unwrap_or(self.pending_start);
if self.autoplay {
self.snapshot.phase = Phase::Playing;
self.audible = true;
} else {
self.snapshot.phase = Phase::Ready;
}
}
/// Advance playback, for tests that care about time passing.
pub fn tick(&mut self, by: Duration) {
if self.snapshot.phase.is_active() {
self.snapshot.position = (self.snapshot.position + by).min(self.duration);
if self.snapshot.position >= self.duration {
self.snapshot.phase = Phase::Ended;
self.audible = false;
}
}
}
pub fn fail_open(&mut self, why: &str) {
self.snapshot.phase = Phase::Failed(why.to_string());
self.audible = false;
}
}
impl MediaPlayer for FakePlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Opened {
url: req.selection.url.clone(),
start: req.start,
});
self.snapshot = PlaybackSnapshot {
phase: Phase::Opening,
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
audio_track: req.audio_track,
subtitle_track: req.subtitle_track,
..PlaybackSnapshot::default()
};
self.pending_start = req.start;
self.deferred_seek = None;
self.autoplay = req.autoplay;
self.audible = false;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Played);
if self.snapshot.phase.has_media() {
if self.snapshot.phase == Phase::Opening {
self.autoplay = true;
} else {
self.snapshot.phase = Phase::Playing;
self.audible = true;
}
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Paused);
if self.snapshot.phase == Phase::Opening {
self.autoplay = false;
} else if self.snapshot.phase.has_media() {
self.snapshot.phase = Phase::Paused;
self.audible = false;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Closed);
self.snapshot = PlaybackSnapshot {
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
..PlaybackSnapshot::default()
};
self.pending_start = Duration::ZERO;
self.deferred_seek = None;
// An open that was still in flight must not come back to life.
self.autoplay = false;
self.audible = false;
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Sought(to));
match self.snapshot.phase {
// The window DR-241 lived in: hold it, do not discard it.
Phase::Opening => self.deferred_seek = Some(to),
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => self.snapshot.position = to.min(self.duration),
}
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.snapshot.volume = volume.clamp(0.0, 1.0);
Ok(())
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.snapshot.muted = muted;
Ok(())
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.snapshot.rate = rate;
Ok(())
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.audio_track = index;
Ok(())
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.subtitle_track = index;
Ok(())
}
fn snapshot(&self) -> PlaybackSnapshot {
self.snapshot.clone()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
}
@@ -1,56 +0,0 @@
//! `FakePlayer` runs the conformance suite.
//!
//! It is correct by construction, so a failure here means the *suite* is wrong,
//! not an engine. That is what makes it safe to trust the same cases when they
//! fail against a real one.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::conformance::Harness;
use super::fake_player::FakePlayer;
use super::media::MediaItem;
use super::media_player::OpenRequest;
use crate::repository::stream_selection::StreamSelection;
struct FakeHarness {
player: FakePlayer,
}
impl FakeHarness {
fn new() -> Self {
Self {
player: FakePlayer::new().with_duration(Duration::from_secs(7200)),
}
}
}
impl Harness for FakeHarness {
type Player = FakePlayer;
fn player(&mut self) -> &mut FakePlayer {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file("http://example.invalid/stream.mp4");
let media = MediaItem::sample("fake-item", &selection.url);
OpenRequest::new(media, selection).starting_at(start)
}
fn settle(&mut self) {
self.player.complete_open();
}
fn audible(&mut self) -> Option<bool> {
Some(self.player.audible)
}
/// Exact: the fake has no keyframes to round to, so any drift is a bug.
fn seek_tolerance(&self) -> Duration {
Duration::ZERO
}
}
crate::media_player_conformance!(fake, FakeHarness::new());
-258
View File
@@ -1,258 +0,0 @@
//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
//!
//! Two purposes.
//!
//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
//! null backend — keep working while `PlayerController` moves onto the new
//! contract (DR-245). Without this the port would have to land all four engines
//! at once.
//!
//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
//! then `play`, then `seek` for a start position. Running the conformance suite
//! against it therefore shows the old path failing the cases the new one passes,
//! on the same engine and the same media — which is the difference between
//! asserting that a design was wrong and demonstrating it.
//!
//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
//! pass would defeat the point.
//!
//! TRACES: UR-081 | DR-245
use std::time::Duration;
use super::backend::{PlayerBackend, PlayerError};
use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use super::state::PlayerState;
pub struct LegacyPlayer<B: PlayerBackend> {
inner: B,
/// Declared at construction: this wrapper is generic over engines with very
/// different abilities, and only the composition root knows which one it
/// just built. Guessing here would reintroduce exactly the inference DR-238
/// removed.
capabilities: Capabilities,
/// The old trait has no notion of "opening", so this is the best the wrapper
/// can do: it knows an item was handed over, not whether the engine is ready
/// for one. That gap is the whole problem.
has_item: bool,
}
impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B, capabilities: Capabilities) -> Self {
Self {
inner,
capabilities,
has_item: false,
}
}
}
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
/// Load, play, then seek — the sequence every caller used to write.
///
/// The seek is issued immediately, because a caller has no way to know when
/// the engine becomes ready. On an engine whose load is asynchronous it
/// fails and is discarded, and playback begins at zero: DR-241, reproduced.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.inner.load(&req.media)?;
self.has_item = true;
if req.autoplay {
self.inner.play()?;
}
if !req.start.is_zero() {
// Faithfully ignoring the failure, exactly as the old callers did.
let _ = self.inner.seek(req.start.as_secs_f64());
}
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.inner.play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.inner.pause()
}
fn close(&mut self) -> Result<(), PlayerError> {
self.has_item = false;
self.inner.stop()
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.inner.seek(to.as_secs_f64())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.inner.set_volume(volume)
}
/// The old trait has no mute. Folding it into volume would lose the user's
/// level, so this reports unsupported rather than pretending.
fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
Err(PlayerError {
message: "mute is not supported by this backend".to_string(),
})
}
fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
Err(PlayerError {
message: "playback rate is not supported by this backend".to_string(),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_audio_track(index.unwrap_or(-1))
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_subtitle_track(index)
}
fn snapshot(&self) -> PlaybackSnapshot {
let phase = match self.inner.state() {
_ if !self.has_item => Phase::Idle,
PlayerState::Playing { .. } => Phase::Playing,
PlayerState::Paused { .. } => Phase::Paused,
PlayerState::Idle => Phase::Idle,
PlayerState::Error { error, .. } => Phase::Failed(error),
// `Loading` is the closest the old trait comes to an opening state,
// but it is set once the engine has accepted the item rather than
// while it is still accepting it — which is precisely the window it
// cannot describe.
PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
};
PlaybackSnapshot {
phase,
position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
duration: self.inner.duration().and_then(duration_from_secs),
seekable: true,
volume: self.inner.volume(),
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
fn set_audio_settings(
&mut self,
settings: &crate::settings::AudioSettings,
) -> Result<(), PlayerError> {
self.inner.set_audio_settings(settings)
}
fn audio_settings(&self) -> crate::settings::AudioSettings {
self.inner.audio_settings()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::media::MediaItem;
use crate::settings::AudioSettings;
/// A backend that answers badly, on purpose.
///
/// Every engine the conformance suite drives reports sane numbers, which is
/// why it passed while a real one did not: ExoPlayer returns
/// `C.TIME_UNSET` — `Long::MIN_VALUE`, about -9.2e15 seconds — for any
/// stream whose length it does not know, and the adapter converted that
/// straight into a `Duration` and panicked the whole backend.
///
/// The old `PlayerBackend` contract is a plain `f64`. It never promised
/// finite, never promised positive, and nothing enforced it. So this is the
/// engine the suites were missing.
struct HostileBackend {
duration: f64,
position: f64,
}
impl PlayerBackend for HostileBackend {
fn load(&mut self, _media: &MediaItem) -> Result<(), PlayerError> {
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn seek(&mut self, _position: f64) -> Result<(), PlayerError> {
Ok(())
}
fn set_volume(&mut self, _volume: f32) -> Result<(), PlayerError> {
Ok(())
}
fn position(&self) -> f64 {
self.position
}
fn duration(&self) -> Option<f64> {
Some(self.duration)
}
fn state(&self) -> PlayerState {
PlayerState::Idle
}
fn volume(&self) -> f32 {
1.0
}
fn set_audio_settings(&mut self, _s: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
AudioSettings::default()
}
fn set_audio_track(&mut self, _i: i32) -> Result<(), PlayerError> {
Ok(())
}
fn set_subtitle_track(&mut self, _i: Option<i32>) -> Result<(), PlayerError> {
Ok(())
}
}
fn hostile(duration: f64, position: f64) -> LegacyPlayer<HostileBackend> {
LegacyPlayer::new(
HostileBackend { duration, position },
crate::player::media_player::Capabilities::mpv(),
)
}
/// Reading an engine that answers badly must not take the process down.
///
/// This is DR-252 as a test. It fails — by panicking — against the adapter
/// as originally written, which is the property the conformance suite could
/// not have: it only ever drove engines that behave.
///
/// TRACES: UR-005 | DR-252 | UT-223
#[test]
fn test_snapshot_survives_an_engine_that_answers_badly() {
// The exact value ExoPlayer reports for an unknown length.
let s = hostile(-9_223_372_036_854_776.0, 0.0).snapshot();
assert_eq!(s.duration, None, "a negative duration is not a duration");
for bad in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0] {
let s = hostile(bad, bad).snapshot();
assert_eq!(s.duration, None, "{bad} should not become a duration");
assert_eq!(
s.position,
Duration::ZERO,
"{bad} should not become a position"
);
}
// And a well-behaved engine still works.
let s = hostile(6997.024, 540.0).snapshot();
assert_eq!(s.duration, Some(Duration::from_secs_f64(6997.024)));
assert_eq!(s.position, Duration::from_secs_f64(540.0));
}
}
+3 -51
View File
@@ -185,16 +185,10 @@ impl MediaItem {
}
}
/// The URL or path an engine should open.
/// Get the playback URL or file path
///
/// Not gated to Android any more. It was, back when only ExoPlayer needed
/// direct URL access — and that gate is why a byte-identical copy was later
/// added for the cross-platform `MediaPlayer::open` path without anyone
/// noticing this existed: it is invisible in a Linux build, so nothing
/// warned. Two matches over `MediaSource` meant a new variant could be
/// handled in one and forgotten in the other, silently.
///
/// TRACES: UR-081 | DR-245, DR-255
/// Only available on Android where ExoPlayer needs direct URL access
#[cfg(target_os = "android")]
pub fn playback_url(&self) -> String {
match &self.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
@@ -204,48 +198,6 @@ impl MediaItem {
}
}
impl MediaItem {
/// A minimal item for tests.
///
/// The struct has twenty-odd fields, almost none of which any given test
/// cares about, and repeating the literal per test is how a new field ends
/// up added in thirty places. Set what matters on the result.
///
/// TRACES: UR-081 | DR-243
#[cfg(any(test, feature = "conformance"))]
pub fn sample(id: &str, url: &str) -> Self {
Self {
transport: None,
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: url.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
-328
View File
@@ -1,328 +0,0 @@
//! The `MediaPlayer` contract: one API, interchangeable engines.
//!
//! See docs/specs/media-player-controller.md.
//!
//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
//! distinction is not academic; it produced four shipped defects in one day:
//!
//! * A start position was not expressible, so every caller sequenced
//! `load()` + `seek()` itself and each raced the engine's asynchronous load
//! independently. Resume worked through one caller and silently failed through
//! another (DR-241).
//! * Whether a stream could be seeked in place was decided *above* the engines,
//! by a truth table in a command handler, for engines it does not own (DR-238).
//! * Nothing in the contract obliged an engine to report its own state, so a
//! handler for mpv's `pause` property sat unreachable and the play/pause
//! control never moved (DR-239).
//!
//! The contract below is written so each of those is a compile-time or
//! conformance-time failure rather than a runtime surprise.
//!
//! TRACES: UR-081 | DR-242
// Scaffolding: nothing consumes this contract until `PlayerController` is
// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
// code being built in shippable steps, not a test fixture. Remove this allow
// when the controller talks to `MediaPlayer`.
#![allow(dead_code)]
use std::time::Duration;
use super::backend::PlayerError;
use super::media::MediaItem;
use crate::repository::stream_selection::StreamSelection;
use crate::settings::AudioSettings;
/// Seconds reported by an engine, as a `Duration`, without trusting the number.
///
/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
/// chunked, length-less transcode.
///
/// Held as a float that junk was harmless. Converted to a `Duration` it became
/// a panic that killed the backend mid-handoff and left a black screen with no
/// controls. Every engine crossing into this contract goes through here.
///
/// TRACES: UR-005 | DR-252
pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
(seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
}
/// What an engine is doing right now.
///
/// `Opening` is the state the previous design could not express, and is the
/// direct cause of DR-241: a seek that arrived while the engine had nothing
/// loaded had no phase to be queued against, so it was simply discarded and
/// playback began at zero.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
/// Nothing loaded. `close()` must reach this, and must be silent here.
Idle,
/// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
/// now must be honoured once the engine reaches `Ready`, never dropped.
Opening,
/// Loaded and able to play, but not advancing.
Ready,
Playing,
Paused,
/// Reached the end of the item by itself. Distinct from `Idle`, because
/// autoplay cares which one happened.
Ended,
Failed(String),
}
impl Phase {
/// Whether the engine currently holds an item.
pub fn has_media(&self) -> bool {
!matches!(self, Phase::Idle | Phase::Failed(_))
}
/// Whether playback is advancing.
pub fn is_active(&self) -> bool {
matches!(self, Phase::Playing)
}
}
/// Everything the UI consumes, read as one coherent value.
///
/// Deliberately a single snapshot rather than a dozen getters: reading position
/// and duration through separate calls is how a paused player reported
/// `<position> / 0.0` when a file unloaded between them.
#[derive(Debug, Clone)]
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
/// `None` while unknown — a live stream, or an item still opening.
pub duration: Option<Duration>,
/// Whether `seek` can be expected to land. False for live edges.
pub seekable: bool,
/// 0.0 1.0.
pub volume: f32,
pub muted: bool,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
impl Default for PlaybackSnapshot {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
}
/// What an engine can do, so callers adapt without naming engines.
///
/// If a caller ever branches on *which* engine it holds, this struct is missing
/// something — add it here rather than sniffing. Engine identity leaking into
/// callers is the coupling DR-238 came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
/// The engine renders pictures, not only sound.
pub video: bool,
/// Audio settings (EQ, normalisation, gapless) are honoured.
pub audio_settings: bool,
/// Subtitle tracks can be selected without re-opening.
pub subtitle_switching: bool,
/// Audio tracks can be selected without re-opening.
pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
/// Declared by the engine rather than inferred by the caller. The previous
/// design decided this from `is_hls` and `use_html5` in a command handler —
/// on behalf of engines it did not own — which is how "who renders" came to
/// mean "how do I seek" and why a transcoded seek silently did nothing the
/// moment native video changed the renderer (DR-238).
///
/// Re-negotiating a stream needs the repository, which sits above the
/// engine, so the engine states the capability and the caller acts on it.
pub seeks_transcoded_in_place: bool,
}
impl Capabilities {
/// mpv.
///
/// Cannot seek a server-side transcode in place: its HLS demuxer will not
/// make the server produce segments from a new offset, so the stream has to
/// be re-opened.
pub fn mpv() -> Self {
Self {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
seeks_transcoded_in_place: false,
}
}
/// ExoPlayer.
///
/// **Can** seek a transcode in place. It is a full HLS client, so like
/// hls.js it seeks within the VOD playlist it was handed and lets the
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category.
pub fn exoplayer() -> Self {
Self {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
seeks_transcoded_in_place: true,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
}
/// A request to present an item.
///
/// `start` is the reason this type exists. Carrying it here — rather than
/// leaving callers to `seek` after `open` — is what closes the load/seek race,
/// because the engine is the only layer that knows when its pipeline can accept
/// a position.
#[derive(Debug, Clone)]
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection,
/// Where to begin. `Duration::ZERO` means the start of the item.
pub start: Duration,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
/// Begin playing as soon as the engine is able.
pub autoplay: bool,
}
impl OpenRequest {
/// Open at the beginning, playing.
pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
Self {
media,
selection,
start: Duration::ZERO,
audio_track: None,
subtitle_track: None,
autoplay: true,
}
}
pub fn starting_at(mut self, start: Duration) -> Self {
self.start = start;
self
}
}
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. An engine that cannot start at an offset
/// natively absorbs that internally — by deferring until loaded, or by
/// re-opening — because it is the only layer that knows when it can.
/// Callers must never follow `open` with a `seek` to achieve a start
/// position; that is the bug this signature exists to prevent.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item.
///
/// Must be **idempotent** and must leave the engine **silent**. "Stopped"
/// and "producing no audio" were not the same thing in the previous design,
/// and the gap between them is audible.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of the engine's state.
fn snapshot(&self) -> PlaybackSnapshot;
fn capabilities(&self) -> Capabilities;
/// Apply EQ, normalisation and gapless settings.
///
/// Provided rather than required: engines that cannot honour them say so
/// through [`Capabilities::audio_settings`] and inherit this no-op, instead
/// of every implementation carrying an `Ok(())` it does not mean.
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
AudioSettings::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The value that killed the backend: `C.TIME_UNSET` as seconds.
///
/// ExoPlayer reports it for any stream whose length it does not know, and
/// `Duration::from_secs_f64` panics on it. A player must not be the place
/// anyone discovers a float was strange.
///
/// TRACES: UR-005 | DR-252 | UT-222
#[test]
fn test_junk_durations_do_not_panic() {
// Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
assert_eq!(duration_from_secs(-1.0), None);
assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
assert_eq!(duration_from_secs(f64::NAN), None);
assert_eq!(duration_from_secs(f64::INFINITY), None);
assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
// A real one still survives.
assert_eq!(
duration_from_secs(6997.024),
Some(Duration::from_secs_f64(6997.024))
);
}
}
+38 -500
View File
@@ -5,25 +5,14 @@
pub mod autoplay;
pub mod backend;
pub mod background_policy;
#[cfg(any(test, feature = "conformance"))]
pub mod conformance;
pub mod events;
#[cfg(any(test, feature = "conformance"))]
pub mod fake_player;
#[cfg(test)]
mod fake_player_conformance;
pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(target_os = "linux")]
pub mod mpv_player;
pub mod queue;
pub mod seek;
pub mod session;
pub mod sleep_timer;
pub mod state;
pub mod stream_end;
pub mod track_switch;
#[cfg(test)]
mod mpv_backend_test;
@@ -35,22 +24,11 @@ pub mod android;
#[cfg(target_os = "linux")]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
/// (UR-080 / DR-231, DR-235).
pub mod native_video;
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
///
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
/// everything here is the portable half, and Windows reuses it unchanged behind
/// its own surface.
#[cfg(target_os = "linux")]
pub mod mpv_render;
/// The native video surface mpv renders into (UR-080 / DR-231).
///
/// Linux-gated because the *surface* is GTK. Everything around it — the render
/// context, its lifetime, frame pacing, the device profile — is not.
/// Linux-gated for now because the surface is GTK. Everything *around* it — the
/// render context, its lifetime, frame pacing, the device profile — is
/// deliberately not, so Windows reuses it behind its own surface.
#[cfg(target_os = "linux")]
pub mod video_surface;
@@ -60,19 +38,15 @@ pub mod video_surface;
pub mod webview_audio_backend;
// Re-export commonly used types
use crate::repository::stream_selection::StreamSelection;
pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use legacy_player::LegacyPlayer;
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
pub use media_player::{MediaPlayer, OpenRequest, Phase};
pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState};
pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
// Re-export platform-specific backends
#[cfg(target_os = "android")]
@@ -242,9 +216,7 @@ use crate::utils::conversions::seconds_to_ticks;
/// Central player controller that coordinates playback
pub struct PlayerController {
/// The engine. One contract, so the controller stops branching on which
/// platform it is running on — see docs/specs/media-player-controller.md.
backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
muted: bool,
@@ -343,7 +315,7 @@ pub struct PlayerController {
impl PlayerController {
pub fn new(
backend: Box<dyn MediaPlayer>,
backend: Box<dyn PlayerBackend>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Self {
@@ -521,12 +493,7 @@ impl PlayerController {
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
///
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
/// a runtime question — "does this renderer draw the picture?" — so the
/// `else` arm is compiled on every platform even where it never runs. The
/// gate outliving its caller broke the Android build outright, which went
/// unnoticed because nothing built for Android afterwards.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
@@ -579,16 +546,8 @@ impl PlayerController {
*self.html5_playing.lock_safe() = None;
let mut backend = self.backend.lock_safe();
// One operation: the engine is handed the item and where to begin, so
// there is no window between them for a position to be lost in.
backend.open(OpenRequest::new(
item.clone(),
StreamSelection::for_queued_item(
item.playback_url(),
item.transport,
item.needs_transcoding,
),
))?;
backend.load(item)?;
backend.play()?;
drop(backend);
// A different item is loading; the last one's reported position must not
@@ -762,7 +721,7 @@ impl PlayerController {
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.snapshot().phase.is_active() {
if backend.state().is_playing() {
backend.pause()
} else {
backend.play()
@@ -787,32 +746,10 @@ impl PlayerController {
let position = self.absolute_position();
let mut backend = self.backend.lock_safe();
backend.close()?;
backend.stop()?;
drop(backend);
self.clear_reported_time();
// Stopping means *nothing is playing*, from any renderer — not "the
// thing we currently believe owns playback has been asked to stop".
//
// A background-audio handoff swaps which renderer that is, and the swap
// is bookkeeping that can be mid-flight: `exit_background_audio` marks
// the webview element the player again the moment it is called, while
// the element has not reloaded yet. A stop aimed at what the flags say
// is playing therefore misses the audio stream that actually is, and it
// resurfaces in the mini player as an audio track.
//
// Clearing the handoff here is the other half of that: a stop that
// leaves the base offset and the active flag behind lets the next
// position read be interpreted against a handoff that no longer exists.
//
// TRACES: UR-040, UR-005 | DR-250
if self.is_background_audio_active() {
debug!("[PlayerController] stop: clearing an active background-audio handoff");
}
*self.background_audio_active.lock_safe() = false;
self.set_background_audio_base(0.0);
*self.html5_playing.lock_safe() = None;
if let Some(jellyfin_id) = jellyfin_id {
self.report_stopped_at(jellyfin_id, position);
}
@@ -890,7 +827,7 @@ impl PlayerController {
// If we're more than 3 seconds in, restart current track
{
let backend = self.backend.lock_safe();
if backend.snapshot().position.as_secs_f64() > 3.0 {
if backend.position() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend);
return self.seek(0.0);
@@ -922,7 +859,7 @@ impl PlayerController {
/// timeline and is what every caller outside the player itself means.
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.seek(Duration::from_secs_f64(position.max(0.0)))
backend.seek(position)
}
/// Seek to an **absolute** position on the item's own timeline.
@@ -973,48 +910,23 @@ impl PlayerController {
/// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.select_audio_track(Some(stream_index))
backend.set_audio_track(stream_index)
}
/// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.select_subtitle_track(stream_index)
backend.set_subtitle_track(stream_index)
}
/// Get current state
pub fn state(&self) -> PlayerState {
let phase = self.backend.lock_safe().snapshot().phase;
let media = self.queue.lock_safe().current().cloned();
match (phase, media) {
(Phase::Playing, Some(media)) => PlayerState::Playing {
media,
position: self.position(),
duration: self.duration().unwrap_or(0.0),
},
(Phase::Paused, Some(media)) => PlayerState::Paused {
media,
position: self.position(),
duration: self.duration().unwrap_or(0.0),
},
(Phase::Opening, Some(media)) => PlayerState::Loading { media },
(Phase::Failed(error), media) => PlayerState::Error { media, error },
// Ready without an item, or anything terminal, reads as idle: the
// queue is what says whether there is something to resume.
_ => PlayerState::Idle,
}
}
/// What the engine currently rendering can do.
///
/// TRACES: UR-081 | DR-246
pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
self.backend.lock_safe().capabilities()
self.backend.lock_safe().state()
}
/// Get current position
pub fn position(&self) -> f64 {
self.backend.lock_safe().snapshot().position.as_secs_f64()
self.backend.lock_safe().position()
}
/// The position on the **item's own timeline**, whatever is rendering it.
@@ -1040,7 +952,7 @@ impl PlayerController {
///
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
pub fn absolute_position(&self) -> f64 {
let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
let native = self.backend.lock_safe().position().max(0.0);
let reported = self.reported_time.lock_safe().last_position();
let base = if self.is_background_audio_active() {
*self.background_audio_base.lock_safe()
@@ -1104,34 +1016,10 @@ impl PlayerController {
///
/// TRACES: UR-005 | DR-178
pub fn duration(&self) -> Option<f64> {
// Zero is not a duration, it is an engine saying it does not know yet.
//
// ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
// `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
// answers `Some(0.0)`, every "unknown duration" fallback below is
// skipped, and the seek bar is left with no scale. That presents as
// scrubbing being broken rather than as a duration that never arrived.
//
// The item usually knows: the catalog carried a runtime long before
// anything started decoding.
//
// TRACES: UR-005, UR-040 | DR-251
let usable = |d: f64| (d > 0.0).then_some(d);
self.backend
.lock_safe()
.snapshot()
.duration
.map(|d| d.as_secs_f64())
.and_then(usable)
.or_else(|| self.observed_duration().and_then(usable))
.or_else(|| {
self.queue
.lock_safe()
.current()
.and_then(|item| item.duration)
.and_then(usable)
})
.duration()
.or_else(|| self.observed_duration())
}
/// Get queue reference
@@ -1186,7 +1074,7 @@ impl PlayerController {
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock_safe().snapshot().volume
self.backend.lock_safe().volume()
}
/// Check if muted
@@ -1287,7 +1175,7 @@ impl PlayerController {
drop(timer);
// Stop the backend
if let Err(e) = backend.lock_safe().close() {
if let Err(e) = backend.lock_safe().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e);
}
continue;
@@ -1997,15 +1885,6 @@ impl PlayerController {
&self,
next_episode_id: &str,
) -> Result<(), String> {
// A new episode is a new playback, so a ceiling chosen for the previous
// one does not carry into it. Every advance the frontend drives goes
// through `player_play_item` and is cleared there; this one loads the
// next episode in Rust and would otherwise keep the old cap forever,
// with nothing in the UI saying why. Cleared before the URL is built,
// since that is what reads it.
// TRACES: UR-074 | DR-254
crate::repository::online::clear_playback_quality_override();
let repo = self
.repository
.lock_safe()
@@ -2251,12 +2130,6 @@ impl PlayerController {
return Ok(Some((current_repo_item, next.clone())));
} else {
log::info!("[PlayerController] Current episode is the last in the season");
if let Some(next) = self
.first_episode_of_next_season(&current_repo_item, repo)
.await
{
return Ok(Some((current_repo_item, next)));
}
}
} else {
log::info!(
@@ -2272,97 +2145,6 @@ impl PlayerController {
Ok(None)
}
/// The first episode of the season after this one, if the series has one.
///
/// A season boundary is not the end of a series, and stopping there is felt
/// most sharply on the background-audio path (UR-040): the screen is locked,
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
/// other autoplay entry point shares this lookup, so foreground video and
/// the Android native path cross the boundary too.
///
/// Lookup failures degrade to `None` rather than an error: the episode has
/// already finished, and the caller's only alternative is to stop anyway.
///
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
async fn first_episode_of_next_season(
&self,
current: &crate::repository::types::MediaItem,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Option<crate::repository::types::MediaItem> {
use crate::repository::types::GetItemsOptions;
let series_id = current.series_id.as_deref()?;
let season_id = current.season_id.as_deref()?;
let season_options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Season".to_string()]),
..Default::default()
};
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
Ok(result) => result.items,
Err(e) => {
log::warn!(
"[PlayerController] Season lookup failed for series {}: {}",
series_id,
e
);
return None;
}
};
// Same client-side sort as the episode list: the offline repository
// ignores sort_by and orders by sort_name instead.
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
for season in &seasons[current_idx + 1..] {
// Never roll into Specials. Jellyfin numbers them 0, so they sort
// ahead of season 1 and are normally unreachable from here -- but a
// server that leaves the index unset sorts them last, right where
// this walk would otherwise land.
if season.index_number == Some(0) {
continue;
}
let episode_options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Episode".to_string()]),
..Default::default()
};
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
Ok(result) => result.items,
Err(e) => {
log::warn!(
"[PlayerController] Episode lookup failed for season {}: {}",
season.id,
e
);
return None;
}
};
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
// An empty season is a gap in the series, not the end of it.
if let Some(first) = episodes.into_iter().next() {
log::info!(
"[PlayerController] Rolling over to {} of {}: {}",
first.name,
season.name,
first.id
);
return Some(first);
}
}
log::info!("[PlayerController] No further season to roll over into");
None
}
/// Start autoplay countdown thread
pub fn start_autoplay_countdown(
&self,
@@ -2408,10 +2190,7 @@ impl Default for PlayerController {
let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new());
Self::new(
Box::new(LegacyPlayer::new(
NullBackend::new(),
crate::player::media_player::Capabilities::mpv(),
)),
Box::new(NullBackend::new()),
playback_reporter,
position_throttler,
)
@@ -2420,107 +2199,6 @@ impl Default for PlayerController {
#[cfg(test)]
mod tests {
/// Advancing to the next episode drops a per-playback quality override.
///
/// The override is process-wide and describes *one* playback: a viewer who
/// drops to 720p for a struggling episode has said nothing about the next
/// one. `player_play_item`, `player_play_queue` and `player_play_tracks`
/// all clear it, so every advance the frontend drives is covered — but the
/// background audio-only advance loads the next episode in Rust and skips
/// all three, so every later episode stayed capped at the old quality with
/// nothing in the UI saying so.
///
/// A wiring assertion, like UT-218 and UT-225: the call site is what
/// matters, and reaching it at runtime needs a repository, a server and a
/// live player.
///
/// TRACES: UR-074 | DR-254 | UT-226
#[test]
fn test_background_episode_advance_clears_the_quality_override() {
let src = include_str!("mod.rs");
let start = src
.find("fn advance_to_next_episode_audio_only")
.expect("advance_to_next_episode_audio_only not found");
let rest = &src[start..];
let end = rest.find("\n pub ").unwrap_or(rest.len());
let body = &rest[..end];
assert!(
body.contains("clear_playback_quality_override"),
"the background episode advance does not clear the per-playback \
quality override, so a ceiling chosen for one episode silently \
caps every episode after it"
);
}
/// Stopping clears a background-audio handoff.
///
/// This was verified by listening to a tablet, which is not a test. The
/// handoff swaps which renderer owns playback, and the swap is bookkeeping:
/// leaving the base offset and the active flag behind after a stop lets a
/// later position read be interpreted against a handoff that no longer
/// exists, and left the film playing on as an audio track in the mini
/// player.
///
/// TRACES: UR-040, UR-005 | DR-250 | UT-224
#[test]
fn test_stop_clears_an_active_background_audio_handoff() {
let controller = PlayerController::default();
let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock_safe();
queue.set_queue(vec![item], 0);
}
controller.enter_background_audio(557.5);
assert!(
controller.is_background_audio_active(),
"precondition: the handoff is active"
);
controller.stop().expect("stop failed");
assert!(
!controller.is_background_audio_active(),
"a stop must not leave a handoff behind for the next position read"
);
assert_eq!(
*controller.background_audio_base.lock_safe(),
0.0,
"the handoff base must be cleared with it"
);
}
/// A duration the engine does not know must fall back to the one the item
/// carries, and zero must count as "does not know".
///
/// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
/// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
/// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
/// skipped. The seek bar then has no scale, which presents as scrubbing
/// being dead rather than as a missing duration.
///
/// TRACES: UR-005, UR-040 | DR-251 | UT-221
#[test]
fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
let controller = PlayerController::default();
let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
item.duration = Some(1800.0);
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock_safe();
queue.set_queue(vec![item], 0);
}
assert_eq!(
controller.duration(),
Some(1800.0),
"an engine that cannot report a duration should not erase the one the item carries"
);
}
use super::*;
/// Test emitter that captures events for asserting the HTML5 report methods
@@ -3782,67 +3460,23 @@ mod tests {
/// lookup tests. Only `get_item` and `get_items` are used by
/// `fetch_next_episode_for_item`; everything else is unreachable.
struct MockEpisodeRepo {
/// Seasons in the order the series lists them, each with its episodes.
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
episodes: Vec<repo_types::MediaItem>,
}
impl MockEpisodeRepo {
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
fn season(count: usize) -> Self {
Self::series(&[count])
}
/// A series whose seasons hold the given episode counts. Season 1 keeps
/// the `ep{n}` ids the single-season tests use; later seasons get
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
fn series(counts: &[usize]) -> Self {
let seasons = counts
.iter()
.enumerate()
.map(|(s, count)| {
let season_number = s as i32 + 1;
let episodes = (1..=*count)
.map(|i| {
let id = if season_number == 1 {
format!("ep{}", i)
} else {
format!("s{}e{}", season_number, i)
};
make_repo_episode_in(season_number, &id, i as i32)
})
.collect();
(make_repo_season(season_number), episodes)
let episodes = (1..=count)
.map(|i| {
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
item.name = format!("Episode {}", i);
item
})
.collect();
Self { seasons }
}
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
}
}
fn make_repo_season(index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: format!("season{}", index),
name: format!("Season {}", index),
item_type: "Season".to_string(),
kind: crate::domain::MediaKind::Season,
is_folder: true,
parent_id: Some("series1".to_string()),
index_number: Some(index),
season_id: None,
season_name: None,
parent_index_number: None,
..make_repo_episode(&format!("season{}", index), index)
Self { episodes }
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
make_repo_episode_in(1, id, index)
}
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
@@ -3850,7 +3484,7 @@ mod tests {
kind: crate::domain::MediaKind::Episode,
is_folder: false,
server_id: "server".to_string(),
parent_id: Some(format!("season{}", season_number)),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
@@ -3872,9 +3506,9 @@ mod tests {
index_number: Some(index),
series_id: Some("series1".to_string()),
series_name: Some("Test Series".to_string()),
season_id: Some(format!("season{}", season_number)),
season_name: Some(format!("Season {}", season_number)),
parent_index_number: Some(season_number),
season_id: Some("season1".to_string()),
season_name: Some("Season 1".to_string()),
parent_index_number: Some(1),
user_data: None,
media_streams: None,
media_sources: None,
@@ -3892,29 +3526,18 @@ mod tests {
parent_id: &str,
_options: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
// The series lists its seasons; a season lists its episodes. Both
// are real lookups the autoplay path makes -- the second only once
// the first has told it which season comes next.
let items = if parent_id == "series1" {
self.seasons.iter().map(|(s, _)| s.clone()).collect()
} else {
self.seasons
.iter()
.find(|(season, _)| season.id == parent_id)
.map(|(_, eps)| eps.clone())
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
};
let total_record_count = items.len();
assert_eq!(parent_id, "season1", "episode lookup must query the season");
Ok(repo_types::SearchResult {
items,
total_record_count,
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
})
}
async fn get_item(
&self,
item_id: &str,
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.all_episodes()
self.episodes
.iter()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
@@ -4156,91 +3779,6 @@ mod tests {
}
}
/// A season boundary is not the end of the series. The lookup used to stop
/// dead at the last episode of a season, which on Android's background-audio
/// path is felt as playback simply pausing at the end of an episode with the
/// screen locked and nothing to un-pause it.
///
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
#[tokio::test]
async fn test_next_episode_rolls_over_to_the_next_season() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(
next_episode.id, "s2e1",
"the first episode of the next season follows the last of this one"
);
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// A season with nothing in it is not the end of the series either.
///
/// TRACES: UR-023 | DR-263 | UT-238
#[tokio::test]
async fn test_next_episode_skips_an_empty_season() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
let decision = controller
.on_video_playback_ended("ep1", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "s3e1");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// The rollover must not out-rank the sleep timer: crossing a season
/// boundary is still a track boundary, and that is exactly where a timer set
/// to "end of episode" is supposed to stop.
///
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
#[tokio::test]
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
let episode = MediaItem {
transport: None,
media_type: MediaType::Audio,
item_type: Some("Episode".to_string()),
series_id: Some("series1".to_string()),
duration: Some(180.0),
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mp3".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Played through to the end -- a natural finish, not a stream cut short.
controller.seek(180.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::Stop),
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
decision
);
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {
+9 -162
View File
@@ -34,19 +34,6 @@ pub struct MpvBackend {
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
/// know where playback reached. See [`ObservedTime`].
observed: Arc<Mutex<ObservedTime>>,
/// A seek that arrived before MPV had a file to seek in.
///
/// `loadfile` is asynchronous: it returns as soon as the command is queued,
/// so `time-pos` is not yet a resolvable property and setting it fails. A
/// seek issued in that window used to be dropped on the floor, and the two
/// callers that do exactly this are the ones a viewer notices — resume, and
/// a transcoded seek, both of which re-open the stream and then ask for a
/// position. The stream reloaded and played from zero.
///
/// Held here and applied by the `FileLoaded` arm.
///
/// TRACES: UR-040, UR-005 | DR-241
pending_seek: Arc<Mutex<Option<f64>>>,
}
struct InternalState {
@@ -102,32 +89,6 @@ fn get_stream_url(media: &MediaItem) -> String {
}
}
/// The mpv handle of the backend this process created, for the video surface.
///
/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
/// trait is cross-platform and a raw mpv pointer is not something every backend
/// should have to pretend to have. Stored as `usize` because a raw pointer is
/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
/// also where mpv was created.
///
/// Written once at construction and never cleared: the backend outlives the
/// window, so there is no window in which this could dangle while a surface is
/// still using it.
///
/// TRACES: UR-080 | DR-231
static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// The registered handle, or null if no MPV backend was created (initialisation
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
.map(|p| *p as *mut libmpv_sys::mpv_handle)
.unwrap_or(std::ptr::null_mut())
}
impl MpvBackend {
/// Create a new MPV backend
pub fn new(
@@ -176,28 +137,9 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
}
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -236,21 +178,13 @@ impl MpvBackend {
}));
let backend = MpvBackend {
mpv: {
let mpv = Arc::new(mpv);
// Publish the handle for the video surface (DR-231). Ignores a
// second call: only one MPV backend is ever constructed, and a
// failed re-init must not replace a live handle.
let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
mpv
},
mpv: Arc::new(mpv),
state,
event_emitter,
audio_settings: AudioSettings::default(),
playback_reporter,
position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)),
pending_seek: Arc::new(Mutex::new(None)),
observed: Arc::new(Mutex::new(ObservedTime::default())),
};
@@ -268,7 +202,6 @@ impl MpvBackend {
let state = self.state.clone();
let reporter = self.playback_reporter.clone();
let throttler = self.position_throttler.clone();
let pending_seek_for_events = self.pending_seek.clone();
std::thread::spawn(move || {
info!("[MpvBackend] Event loop started");
@@ -278,30 +211,6 @@ impl MpvBackend {
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
});
// libmpv delivers PropertyChange only for properties registered
// here. Every name matched in the loop below needs a line in this
// block or its handler is unreachable — an omission that reads as
// working code, because the handler is sitting right there.
// UT-218 holds the two lists together.
//
// `pause` drives the play/pause control: the UI consumes
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
.observe_property("pause", libmpv::Format::Flag, 0)
.unwrap_or_else(|e| {
error!(
"[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
control will not follow the player",
e
);
});
loop {
match ev_ctx.wait_event(1.0) {
Some(Ok(event)) => match event {
@@ -311,43 +220,6 @@ impl MpvBackend {
libmpv::events::Event::FileLoaded => {
info!("[MpvBackend] File loaded");
// Apply a seek that arrived while there was nothing
// to seek in. TRACES: UR-040, UR-005 | DR-241
{
let target = pending_seek_for_events.lock_safe().take();
if let Some(position) = target {
match mpv.set_property("time-pos", position) {
Ok(()) => info!(
"[MpvBackend] applied deferred seek to {position}"
),
Err(e) => warn!(
"[MpvBackend] deferred seek to {position} failed: {:?}",
e
),
}
}
}
// Geometry, so "the picture does not fill the screen"
// can be attributed rather than guessed at. `width`/
// `height` are the decoded frame; `dwidth`/`dheight`
// are what mpv will *display* after aspect
// correction. A file that carries its letterbox
// baked into the picture reports a 16:9 dwidth and
// is then pillarboxed on a wider panel — which looks
// identical to a rendering bug from outside.
{
let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
info!(
"[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
n("width"),
n("height"),
n("dwidth"),
n("dheight"),
mpv.get_property::<f64>("video-params/aspect").ok(),
);
}
// Get duration
if let Ok(duration) = mpv.get_property::<f64>("duration") {
if let Some(emitter) = &event_emitter {
@@ -592,14 +464,6 @@ impl PlayerBackend for MpvBackend {
// one's "last observed" position.
self.observed.lock_safe().reset();
// Nor its deferred seek. A seek held for a file that is no longer the
// one loading would be applied to this one by the `FileLoaded` handler
// — so scrubbing near the end of a transcoded item, which re-opens the
// stream, and then skipping to the next item before the reload finished
// started the new item wherever the old one had been scrubbed to.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
@@ -642,10 +506,6 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to stop: {:?}", e),
})?;
// Stopping ends the seek's subject along with the playback.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
let mut state = self.state.lock_safe();
state.current_media = None;
@@ -662,24 +522,11 @@ impl PlayerBackend for MpvBackend {
.as_millis() as u64;
self.last_seek_time.store(now, Ordering::Relaxed);
// `time-pos` only resolves while a file is loaded. `loadfile` is
// asynchronous, so a seek issued straight after a reload — resume, or a
// transcoded seek — lands in a window where this fails, and dropping it
// there is what makes the stream play from zero instead of the position
// that was asked for. Hold it and let `FileLoaded` apply it.
// TRACES: UR-040, UR-005 | DR-241
if let Err(e) = self.mpv.set_property("time-pos", position) {
debug!(
"[MpvBackend] seek to {position} deferred until the file loads ({:?})",
e
);
*self.pending_seek.lock_safe() = Some(position);
self.observed.lock_safe().record_position(position);
return Ok(());
}
// A seek that lands clears any earlier deferred one: the newer intent wins.
*self.pending_seek.lock_safe() = None;
self.mpv
.set_property("time-pos", position)
.map_err(|e| PlayerError {
message: format!("Failed to seek: {:?}", e),
})?;
// The poll thread suppresses updates for 150ms after a seek, so without
// this a file ending inside that window would report the pre-seek time.
-83
View File
@@ -13,89 +13,6 @@ mod tests {
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
/// Every property the event loop *handles* must also be *observed*.
///
/// libmpv only delivers `PropertyChange` for properties registered with
/// `mpv_observe_property`. A `match` arm for an unobserved property is
/// unreachable code that looks exactly like working code: the handler is
/// right there, so the behaviour reads as implemented.
///
/// This cost a real bug. `pause` was handled and never observed, so
/// `StateChanged` was never emitted on pause or resume. It stayed invisible
/// while Linux video played in the webview, because the `<video>` element's
/// own DOM events drove the play/pause control; turning native video on made
/// the UI depend on the event that never came, and the button stopped
/// responding.
///
/// Asserted against the source because there is no way to observe the
/// registration at runtime without a live mpv instance.
///
/// TRACES: UR-005 | DR-239 | UT-218
#[test]
fn test_every_handled_property_is_observed() {
let src = include_str!("mpv_backend.rs");
let handled: Vec<&str> = src
.match_indices("PropertyChange { name: \"")
.filter_map(|(i, m)| {
let rest = &src[i + m.len()..];
rest.find('"').map(|end| &rest[..end])
})
.collect();
assert!(
!handled.is_empty(),
"no PropertyChange arms found - has the event loop been restructured?"
);
for name in handled {
let observed = format!("observe_property(\"{name}\"");
assert!(
src.contains(&observed),
"mpv_backend.rs handles PropertyChange for {name:?} but never calls \
observe_property({name:?}, ..). libmpv will never deliver that event, \
so the handler is dead code."
);
}
}
/// A deferred seek belongs to the file it was issued against.
///
/// `seek` holds a position when MPV has nothing loaded yet, and the
/// `FileLoaded` handler applies it (DR-241). Nothing discarded it when a
/// *different* file was loaded or playback stopped — so scrubbing near the
/// end of a transcoded item (which re-opens the stream) and then skipping to
/// the next item before the reload completed applied the old position to the
/// new item. It silently started wherever you had scrubbed to in the
/// previous one.
///
/// Asserted against the source: the state lives behind a live MPV handle,
/// and constructing one needs libmpv and an audio device that CI cannot be
/// assumed to have. Crude, but it pins the one thing that matters — that
/// both lifecycle points discard it.
///
/// TRACES: UR-040, UR-005 | DR-253 | UT-225
#[test]
fn test_load_and_stop_discard_a_deferred_seek() {
let src = include_str!("mpv_backend.rs");
for func in ["fn load(", "fn stop("] {
let start = src
.find(func)
.unwrap_or_else(|| panic!("{func} not found - has the backend been restructured?"));
// The body runs to the next top-level ` fn ` at the same depth.
let rest = &src[start + func.len()..];
let end = rest.find("\n fn ").unwrap_or(rest.len());
let body = &rest[..end];
assert!(
body.contains("pending_seek"),
"{func} does not discard `pending_seek`. A seek held for a file \
that is no longer loading will be applied to whatever loads next."
);
}
}
/// Test that simulates the position update thread spawning async tasks
/// without a Tokio runtime (the bug we just fixed)
#[test]
-383
View File
@@ -1,383 +0,0 @@
//! [`MediaPlayer`] over libmpv.
//!
//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
//! position is applied **at load time**, via mpv's own `start` option, instead
//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
//! after it targets a player that has nothing loaded, fails, and — under the old
//! contract — was discarded. That is DR-241, and it is why resume and transcoded
//! skip both played from zero.
//!
//! A seek arriving during [`Phase::Opening`] is held and applied when the file
//! loads, so no caller has to know where that window begins or ends.
//!
//! TRACES: UR-081, UR-040, UR-005 | DR-244
#![allow(dead_code)] // Wired to PlayerController in DR-245.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use libmpv::Mpv;
use log::{debug, info, warn};
use super::backend::PlayerError;
use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use crate::utils::lock::MutexSafe;
/// State the event thread writes and the caller reads.
#[derive(Debug)]
struct Shared {
phase: Phase,
position: Duration,
duration: Option<Duration>,
seekable: bool,
/// A seek that arrived while opening. Applied on `FileLoaded`.
deferred_seek: Option<Duration>,
/// Cleared by `close()`, so an open still in flight cannot come back to life
/// and start playing after the caller has stopped it.
open_generation: u64,
}
impl Default for Shared {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
deferred_seek: None,
open_generation: 0,
}
}
}
pub struct MpvPlayer {
mpv: Arc<Mpv>,
shared: Arc<Mutex<Shared>>,
volume: f32,
muted: bool,
rate: f64,
audio_track: Option<i32>,
subtitle_track: Option<i32>,
}
/// How the engine should talk to the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
/// Real audio and video. What the app uses.
Real,
/// No audio device, no window. What conformance uses, so the suite can run
/// on a headless runner without claiming the user's speakers.
Null,
}
impl MpvPlayer {
pub fn new(output: Output) -> Result<Self, PlayerError> {
// mpv refuses to start under a non-C LC_NUMERIC, and anything that has
// initialised GTK before us will have set one.
unsafe {
let c = std::ffi::CString::new("C").unwrap();
libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
}
};
match output {
Output::Real => {
set("vo", "libmpv");
}
Output::Null => {
set("ao", "null");
set("vo", "null");
}
}
set("msg-level", "all=warn");
// Survive a blip rather than ending the item on it.
set(
"stream-lavf-o",
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
);
let player = Self {
mpv: Arc::new(mpv),
shared: Arc::new(Mutex::new(Shared::default())),
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
};
player.spawn_events();
Ok(player)
}
fn spawn_events(&self) {
let mpv = self.mpv.clone();
let shared = self.shared.clone();
std::thread::spawn(move || {
let mut ev = mpv.create_event_context();
let _ = ev.disable_deprecated_events();
// Every property matched below must be observed, or libmpv never
// delivers it and the handler is unreachable (DR-239).
for prop in ["pause", "eof-reached"] {
if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
warn!("[MpvPlayer] could not observe {prop}: {e:?}");
}
}
loop {
match ev.wait_event(0.25) {
Some(Ok(libmpv::events::Event::FileLoaded)) => {
let deferred = {
let mut s = shared.lock_safe();
// Closed while opening: do not start.
if s.phase == Phase::Idle {
continue;
}
s.duration = mpv
.get_property::<f64>("duration")
.ok()
.and_then(duration_from_secs);
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
s.phase = Phase::Playing;
s.deferred_seek.take()
};
if let Some(to) = deferred {
debug!("[MpvPlayer] applying deferred seek to {to:?}");
if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
warn!("[MpvPlayer] deferred seek failed: {e:?}");
}
}
}
Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
if let Ok(paused) = mpv.get_property::<bool>("pause") {
let mut s = shared.lock_safe();
if s.phase.has_media() {
s.phase = if paused {
Phase::Paused
} else {
Phase::Playing
};
}
}
}
Some(Ok(libmpv::events::Event::EndFile(reason))) => {
let mut s = shared.lock_safe();
// 0 = EOF. Anything else is a stop, a quit or an error,
// and must not read as "the item finished".
s.phase = if reason == 0 {
Phase::Ended
} else {
Phase::Idle
};
}
Some(Ok(libmpv::events::Event::Shutdown)) => break,
_ => {}
}
if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
let mut s = shared.lock_safe();
if s.phase.has_media() && s.deferred_seek.is_none() {
s.position = Duration::from_secs_f64(pos.max(0.0));
}
}
}
});
}
}
impl MediaPlayer for MpvPlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
*s = Shared {
phase: Phase::Opening,
open_generation: s.open_generation + 1,
..Shared::default()
};
// Report the requested position immediately, so a caller reading
// back during the open sees where it asked to be rather than zero.
s.position = req.start;
}
// The whole point. `start` is applied by mpv as it opens the file, so
// there is no window in which the position can be asked for and lost.
let start = if req.start.is_zero() {
"none".to_string()
} else {
format!("{:.3}", req.start.as_secs_f64())
};
self.mpv
.set_property("start", start.as_str())
.map_err(|e| PlayerError {
message: format!("could not set start position: {e:?}"),
})?;
self.mpv
.set_property("pause", !req.autoplay)
.map_err(|e| PlayerError {
message: format!("could not set pause: {e:?}"),
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
})?;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", false)
.map_err(|e| PlayerError {
message: format!("play failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Playing;
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", true)
.map_err(|e| PlayerError {
message: format!("pause failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Paused;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
// State first: an open still in flight checks this on FileLoaded and
// must not proceed to play after the caller has stopped it.
{
let mut s = self.shared.lock_safe();
*s = Shared {
open_generation: s.open_generation,
..Shared::default()
};
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
}
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
match s.phase {
// Held, not dropped. The caller cannot see this window.
Phase::Opening => {
s.deferred_seek = Some(to);
s.position = to;
return Ok(());
}
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => s.position = to,
}
}
self.mpv
.set_property("time-pos", to.as_secs_f64())
.map_err(|e| PlayerError {
message: format!("seek failed: {e:?}"),
})
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
self.volume = clamped;
self.mpv
.set_property("volume", (clamped as f64) * 100.0)
.map_err(|e| PlayerError {
message: format!("set_volume failed: {e:?}"),
})
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.muted = muted;
self.mpv
.set_property("mute", muted)
.map_err(|e| PlayerError {
message: format!("set_muted failed: {e:?}"),
})
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.rate = rate;
self.mpv
.set_property("speed", rate)
.map_err(|e| PlayerError {
message: format!("set_rate failed: {e:?}"),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.audio_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("aid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_audio_track failed: {e:?}"),
})
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.subtitle_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("sid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_subtitle_track failed: {e:?}"),
})
}
fn snapshot(&self) -> PlaybackSnapshot {
let s = self.shared.lock_safe();
PlaybackSnapshot {
phase: s.phase.clone(),
position: s.position,
duration: s.duration,
seekable: s.seekable,
volume: self.volume,
muted: self.muted,
rate: self.rate,
audio_track: self.audio_track,
subtitle_track: self.subtitle_track,
}
}
fn capabilities(&self) -> Capabilities {
Capabilities {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
// mpv's HLS demuxer cannot make the server transcode from a new
// offset, so a transcoded seek must re-open the stream.
seeks_transcoded_in_place: false,
}
}
}
-405
View File
@@ -1,405 +0,0 @@
//! mpv's render API, driven into an OpenGL framebuffer we own.
//!
//! This is the half of native video that is not GTK: create a render context
//! over the mpv handle the audio backend already drives, render a frame into a
//! texture, and hand that texture id back for the toolkit to composite.
//!
//! Kept apart from `video_surface` deliberately — everything here is portable
//! across the platforms this app targets, while the surface that consumes it is
//! not. Windows reuses this file unchanged (DR-237).
//!
//! TRACES: UR-080 | DR-231, DR-232, IR-033
use std::ffi::{c_void, CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use log::{error, info, warn};
/// GL entry points, resolved once.
///
/// Only the handful needed to own a framebuffer; mpv resolves everything else
/// it needs through [`get_proc_address`].
struct Gl {
gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
bind_framebuffer: unsafe extern "C" fn(u32, u32),
framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
gen_textures: unsafe extern "C" fn(c_int, *mut u32),
delete_textures: unsafe extern "C" fn(c_int, *const u32),
bind_texture: unsafe extern "C" fn(u32, u32),
tex_image_2d:
unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
}
const GL_TEXTURE_2D: u32 = 0x0DE1;
const GL_FRAMEBUFFER: u32 = 0x8D40;
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
const GL_RGBA: u32 = 0x1908;
const GL_RGBA8: c_int = 0x8058;
const GL_UNSIGNED_BYTE: u32 = 0x1401;
const GL_LINEAR: c_int = 0x2601;
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
/// Resolve a GL symbol the way libepoxy actually exports it.
///
/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
/// holding a lazily-resolving function pointer. So the address `dlsym` returns
/// is the address *of the pointer*, not of any code: returning it makes mpv jump
/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
/// call. The value must be read *out of* that location.
///
/// The `epoxy` crate does this correctly and is unusable here — its
/// `gl_generator` dependency pulls a yanked `xml-rs`.
///
/// TRACES: UR-080 | IR-033
unsafe fn resolve(name: &str) -> *mut c_void {
let epoxy_name = match CString::new(format!("epoxy_{name}")) {
Ok(n) => n,
Err(_) => return ptr::null_mut(),
};
let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
if !slot.is_null() {
// The symbol holds the function pointer; return what is stored there.
return *(slot as *mut *mut c_void);
}
// Fall back to a plain symbol, for a GL stack that is not behind epoxy.
match CString::new(name) {
Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
Err(_) => ptr::null_mut(),
}
}
/// What mpv calls to find GL entry points. Same rule as [`resolve`].
unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
if name.is_null() {
return ptr::null_mut();
}
match CStr::from_ptr(name).to_str() {
Ok(n) => resolve(n),
Err(_) => ptr::null_mut(),
}
}
macro_rules! load {
($name:literal) => {{
let p = resolve($name);
if p.is_null() {
error!("[MpvRender] GL symbol not found: {}", $name);
return None;
}
std::mem::transmute(p)
}};
}
impl Gl {
/// Resolve every entry point, or none — a partially-loaded table would fail
/// later at a call site with no context.
///
/// The transmutes are unannotated on purpose: each target type is declared
/// once on the struct field above, and repeating it at the call site would
/// be two places to get the same signature wrong.
#[allow(clippy::missing_transmute_annotations)]
unsafe fn load() -> Option<Self> {
Some(Gl {
gen_framebuffers: load!("glGenFramebuffers"),
delete_framebuffers: load!("glDeleteFramebuffers"),
bind_framebuffer: load!("glBindFramebuffer"),
framebuffer_texture_2d: load!("glFramebufferTexture2D"),
gen_textures: load!("glGenTextures"),
delete_textures: load!("glDeleteTextures"),
bind_texture: load!("glBindTexture"),
tex_image_2d: load!("glTexImage2D"),
tex_parameteri: load!("glTexParameteri"),
check_framebuffer_status: load!("glCheckFramebufferStatus"),
})
}
}
/// A colour-renderable framebuffer mpv draws into, sized to the widget.
struct Target {
fbo: u32,
texture: u32,
width: i32,
height: i32,
}
/// mpv's render context plus the framebuffer it draws into.
///
/// # Lifetime (DR-232)
///
/// The render context must not outlive the GL context it was created against.
/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
/// callback cannot land on a freed pointer, and frees the GL objects while the
/// caller still has the context current. The caller is responsible for making
/// the GL context current around both creation and drop — see `video_surface`.
///
/// This is DR-184 on Android restated: a surface outliving its player. The spike
/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
pub struct MpvRenderContext {
ctx: *mut libmpv_sys::mpv_render_context,
gl: Gl,
target: Option<Target>,
}
// The render context is driven only from the GTK main thread; the update
// callback merely schedules a redraw and touches nothing here.
unsafe impl Send for MpvRenderContext {}
impl MpvRenderContext {
/// Create a render context over an existing mpv handle.
///
/// The GL context must already be current on this thread.
///
/// TRACES: UR-080 | DR-231, IR-033
pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
let gl = Gl::load()?;
let mut init = libmpv_sys::mpv_opengl_init_params {
get_proc_address: Some(get_proc_address),
get_proc_address_ctx: ptr::null_mut(),
};
let mut api_type = CString::new("opengl").ok()?;
// Advanced control is deliberately OFF.
//
// With it on, mpv expects the client to drive rendering to a stricter
// contract than a GTK draw handler can promise — it will wait on us, and
// if we in turn wait on its update callback, neither side proceeds. That
// deadlock presents as a file that loads, renders one frame, and then
// sits there with no audio and a spinner.
//
// Off, mpv is tolerant of being rendered on the toolkit's schedule,
// which is what the frame clock gives us.
let mut advanced: c_int = 0;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
data: api_type.as_ptr() as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
data: &mut init as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
data: &mut advanced as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
// Keep the CString alive until after the call.
let _ = &mut api_type;
if rc < 0 || ctx.is_null() {
error!("[MpvRender] mpv_render_context_create failed: {rc}");
return None;
}
info!("[MpvRender] render context created");
Some(MpvRenderContext {
ctx,
gl,
target: None,
})
}
/// Ask to be told when a new frame is ready.
///
/// Paired with [`report_swap`](Self::report_swap): without both, mpv has
/// nothing to time against. The symptom is misleading — playback looks fine
/// in a window and judders at fullscreen, which reads as a compositing or
/// GPU limit and is neither (DR-233).
///
/// TRACES: UR-080 | DR-233
pub unsafe fn set_update_callback(
&mut self,
callback: libmpv_sys::mpv_render_update_fn,
ctx: *mut c_void,
) {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
}
/// Whether mpv has a new frame waiting.
///
/// Asked of mpv directly rather than inferred from its update callback, and
/// that distinction is the whole of frame pacing here:
///
/// - Waiting only on the callback deadlocks — mpv will not progress until
/// the client renders, so if the client will not render until mpv says
/// so, neither moves. That presents as a file that loads, shows one
/// frame, and then sits silent.
/// - Rendering on *every* frame-clock tick regardless is the opposite
/// error: `report_swap` then claims a presentation far more often than
/// real frames exist, mpv has nothing coherent to time against, and
/// playback judders badly.
///
/// Polling is neither. It runs on the main thread, costs a single atomic
/// read inside mpv, and answers the only question that matters.
///
/// TRACES: UR-080 | DR-233
pub unsafe fn has_frame(&self) -> bool {
let flags = libmpv_sys::mpv_render_context_update(self.ctx);
(flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
}
/// Render the current frame at `width` x `height`, returning the texture id
/// holding it. The GL context must be current.
///
/// TRACES: UR-080 | DR-231
pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
if width <= 0 || height <= 0 {
return None;
}
self.ensure_target(width, height)?;
let target = self.target.as_ref()?;
let mut fbo = libmpv_sys::mpv_opengl_fbo {
fbo: target.fbo as c_int,
w: width as c_int,
h: height as c_int,
internal_format: 0,
};
// GTK's cairo surface has its origin at the top left; mpv defaults to
// OpenGL's bottom-left. Without this the picture is drawn upside down —
// which looks like a broken decode rather than a coordinate convention.
let mut flip: c_int = 1;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
data: &mut fbo as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
data: &mut flip as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
if rc < 0 {
warn!("[MpvRender] render failed: {rc}");
return None;
}
Some(target.texture)
}
/// Tell mpv the frame reached the screen. See [`set_update_callback`].
///
/// TRACES: UR-080 | DR-233
pub unsafe fn report_swap(&self) {
libmpv_sys::mpv_render_context_report_swap(self.ctx);
}
/// Create or resize the framebuffer. Reused across frames — reallocating per
/// frame would churn GPU memory at the display rate.
unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
if let Some(t) = &self.target {
if t.width == width && t.height == height {
return Some(());
}
}
self.drop_target();
let gl = &self.gl;
let mut texture: u32 = 0;
(gl.gen_textures)(1, &mut texture);
(gl.bind_texture)(GL_TEXTURE_2D, texture);
(gl.tex_image_2d)(
GL_TEXTURE_2D,
0,
GL_RGBA8,
width,
height,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
ptr::null(),
);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
(gl.bind_texture)(GL_TEXTURE_2D, 0);
let mut fbo: u32 = 0;
(gl.gen_framebuffers)(1, &mut fbo);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
(gl.framebuffer_texture_2d)(
GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
texture,
0,
);
let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
if status != GL_FRAMEBUFFER_COMPLETE {
error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
(gl.delete_framebuffers)(1, &fbo);
(gl.delete_textures)(1, &texture);
return None;
}
self.target = Some(Target {
fbo,
texture,
width,
height,
});
Some(())
}
unsafe fn drop_target(&mut self) {
if let Some(t) = self.target.take() {
(self.gl.delete_framebuffers)(1, &t.fbo);
(self.gl.delete_textures)(1, &t.texture);
}
}
/// Free everything, with the GL context current.
///
/// Explicit rather than left to `Drop` because the ordering matters and the
/// caller is the only one that can guarantee the GL context is current. See
/// DR-232.
pub unsafe fn destroy(mut self) {
// Unregister first: a callback arriving after the free would be a use
// after free, and it is scheduled from mpv's own threads.
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
self.drop_target();
libmpv_sys::mpv_render_context_free(self.ctx);
self.ctx = ptr::null_mut();
info!("[MpvRender] render context freed");
std::mem::forget(self);
}
}
impl Drop for MpvRenderContext {
fn drop(&mut self) {
if !self.ctx.is_null() {
// Reached only if `destroy` was not called — the GL context may not
// be current, so the GL objects are deliberately leaked rather than
// deleted against whatever context happens to be bound. Freeing the
// render context is still safe and is the part that matters.
warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
unsafe {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
libmpv_sys::mpv_render_context_free(self.ctx);
}
}
}
}
-78
View File
@@ -1,78 +0,0 @@
//! Whether this process renders video natively, answered once.
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
//! capability whose answers drift. Four separate bugs this cycle came from
//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
//! transcode target contradicting a direct-play claim, a codec list hardcoded in
//! a URL builder. One source, read by everyone.
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
///
/// TRACES: UR-080 | DR-231 | UT-216
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
}
}
}
+23 -78
View File
@@ -25,14 +25,12 @@ pub enum VideoSeekStrategy {
///
/// # Arguments
/// * `is_local` - Whether the file is a local download
/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
/// can seek a server-side transcode without re-opening it. Declared by the
/// engine via `Capabilities`, never inferred from the URL or the renderer.
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
seeks_transcoded_in_place: bool,
is_hls: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
@@ -41,39 +39,23 @@ pub fn determine_video_seek_strategy(
return VideoSeekStrategy::LocalNativeSeek;
}
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand.
//
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// transcoded seek must re-negotiate the stream regardless of container.
//
// Before native video shipped, `use_html5` was always true for HLS and the
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work".
if needs_transcoding {
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
// HLS streams and direct play (non-transcoded) support native seeking
if is_hls || !needs_transcoding {
if use_html5 {
// HTML5 backend - frontend handles seeking via videoElement.currentTime
// We don't call backend.seek() because video is in HTML5 element, not in MPV
VideoSeekStrategy::Html5NativeSeek
} else {
// Native backend (MPV) - backend handles seeking
VideoSeekStrategy::BackendNativeSeek
}
} else {
VideoSeekStrategy::BackendNativeSeek
// Transcoded non-HLS streams need server-side seek (reload from new position)
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
}
}
@@ -238,63 +220,26 @@ mod tests {
);
}
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
/// Test video seek strategy for HLS streams
#[test]
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
// HLS with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// A transcode an engine says it can move: seek in place
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
/// A server-side transcode cannot be seeked by the native backend.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
-155
View File
@@ -1,155 +0,0 @@
//! Audio-track switch strategy decision logic.
//!
//! Pure logic, extracted from the command layer so it can be unit-tested in the
//! player core — the sibling of [`super::seek`]. `player_switch_audio_track`
//! turns the resulting [`AudioTrackSwitchStrategy`] into a concrete action.
//!
//! The rule this module exists to state: **an engine can only select a track
//! the stream in front of it actually carries.** A Jellyfin transcode is built
//! around one `AudioStreamIndex`, so the alternate tracks are not in the stream
//! at all — the switch has to re-open it. Only a direct play/stream hands the
//! engine the source file with every track present.
/// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position.
BackendReloadStream,
/// The engine already holds every track — select in place, no reload.
BackendSelectInPlace,
}
/// Decide how to honour an audio-track change.
///
/// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy(
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream
} else {
AudioTrackSwitchStrategy::BackendSelectInPlace
}
}
/// Where to resume after re-opening the stream for a track change.
///
/// `requested` is what the caller supplied; `engine_position` is where the
/// engine itself says it is. The caller wins when it has something real to say,
/// and the engine answers otherwise — which is the whole point: **position is
/// the player's to know**, not the UI's to remember.
///
/// The native path proved why. It has no `<video>` element, so the frontend
/// sent `null`, the command defaulted to `0.0`, and switching audio track
/// re-opened the stream at the beginning of the film — the track changed and
/// the viewer lost their place. A non-finite or negative value is treated the
/// same as absent rather than passed through to a backend that would reject it.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258 | UT-233
pub fn resume_position(requested: Option<f64>, engine_position: f64) -> f64 {
let usable = requested.filter(|p| p.is_finite() && *p > 0.0);
let fallback = if engine_position.is_finite() && engine_position > 0.0 {
engine_position
} else {
0.0
};
usable.unwrap_or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
/// The reported bug, seen on a device: switching audio track changed the
/// track but "restarts from zero". The native path has no `<video>`
/// element, so the frontend passed `null` and the re-opened stream began at
/// the start of the film — logcat: `Re-opened the stream on audio stream 2
/// and resumed at 0` while playback was 22 minutes in.
#[test]
fn a_caller_with_no_position_resumes_where_the_engine_is() {
assert_eq!(resume_position(None, 1337.5), 1337.5);
}
/// The HTML5 path does have an element and its clock is the honest answer
/// there, so what the caller supplies wins.
#[test]
fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
}
/// A position that is not a position — NaN from an element with no
/// metadata, or a negative from a clock read mid-teardown — is treated as
/// absent. Passing it through re-opens at a place no backend accepts.
#[test]
fn a_nonsense_position_falls_back_to_the_engine() {
assert_eq!(resume_position(Some(f64::NAN), 90.0), 90.0);
assert_eq!(resume_position(Some(-5.0), 90.0), 90.0);
assert_eq!(resume_position(None, f64::NAN), 0.0);
}
/// Switching track in the first moments of playback resumes at the start,
/// which is where the viewer actually is.
#[test]
fn the_very_beginning_stays_the_very_beginning() {
assert_eq!(resume_position(None, 0.0), 0.0);
}
/// The reported bug: on Android the audio-track menu did nothing and the
/// default track kept playing.
///
/// Jellyfin had negotiated a transcode (`TranscodeReasons=AudioCodecNot
/// Supported`) whose URL pins `AudioStreamIndex=1`, so ExoPlayer was handed
/// a stream with exactly one audio track — logcat: `Audio tracks: 1`. The
/// native path nonetheless only ever called `setAudioTrack(n)`, which
/// indexes ExoPlayer's audio track *groups* and so found nothing to select:
/// `Invalid audio track index: 1 (available: 1)`, warned and dropped. The
/// track the viewer asked for is not in the stream; it has to be re-opened.
#[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!(
determine_audio_track_switch_strategy(true, false),
AudioTrackSwitchStrategy::BackendReloadStream
);
}
/// A direct play hands the engine the source file, every track included, so
/// ExoPlayer selects in place — no reload, no re-buffer, no lost position.
#[test]
fn a_direct_play_switches_in_place() {
assert_eq!(
determine_audio_track_switch_strategy(false, false),
AudioTrackSwitchStrategy::BackendSelectInPlace
);
}
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
}
+198 -378
View File
@@ -1,412 +1,232 @@
//! The native video surface: mpv drawn *behind* Tauri's webview, without
//! touching the widget tree.
//! The native video surface: a GL area beneath Tauri's own webview.
//!
//! # Why there is no overlay here
//! This is the desktop counterpart of the Android arrangement — a native
//! renderer at the bottom of the stack with a transparent webview drawn over it,
//! so the Svelte controls composite on top of moving video.
//!
//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
//! first click. `tauri-runtime-wry` connects a button-press handler to the
//! webview that walks a hard-coded path:
//! The spike that authorised this built its *own* `GtkOverlay` and proved mpv
//! renders into it on X11 and Wayland. What it could not prove is the step this
//! module exists for: taking the overlay Tauri already built and reparenting the
//! real webview into it. Same widgets, one extra move, and the only place
//! Tauri-specific behaviour can still bite — which is why it is gate one.
//!
//! ```text
//! webview.parent() // "This one should be GtkBox"
//! .parent() // ...and this one the GtkWindow
//! .downcast::<gtk::Window>().unwrap()
//! ```
//!
//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
//! fails, and because the panic is non-unwinding it takes the app with it.
//! Nothing in configuration avoids it: on Linux the handler is attached
//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
//! the decoration check that would make it inert runs *after* the unwrap.
//!
//! So the widget tree is left exactly as Tauri built it. GTK draws a container
//! before its children, so rendering into the vbox's own `draw` handler puts the
//! picture underneath the webview for free — the same z-order, no reparenting,
//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
//! own layout.
//!
//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
use std::cell::RefCell;
use std::ffi::c_void;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
//! TRACES: UR-080 | DR-231, IR-033
use gtk::prelude::*;
use gtk::{gdk, glib};
use log::{error, info, warn};
use log::{info, warn};
use super::mpv_render::MpvRenderContext;
/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
/// constant itself rather than an enum of its own.
const GL_TEXTURE: i32 = 0x1702;
/// Everything the draw handler needs, shared with the GTK callbacks.
struct SurfaceState {
gl: Option<gdk::GLContext>,
render: Option<MpvRenderContext>,
mpv: *mut libmpv_sys::mpv_handle,
/// Set by mpv's update callback (on an mpv thread), cleared by the frame
/// clock (on the main thread). The whole cross-thread contract.
frame_ready: Arc<AtomicBool>,
/// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
/// Null when no callback is registered.
callback_ctx: *mut Arc<AtomicBool>,
// One-shot diagnostic latches; see `draw`.
logged_first_draw: bool,
logged_first_frame: bool,
/// Last size we logged, so a size change re-reports rather than staying silent.
logged_size: (i32, i32),
logged_no_gl: bool,
logged_no_window: bool,
logged_no_size: bool,
logged_render_fail: bool,
}
impl SurfaceState {
/// Tear down in the order DR-232 requires, with the GL context current.
///
/// The update callback is unregistered before the context is freed (inside
/// `destroy`), and the GL objects go while their context is still bound.
/// Getting this wrong is DR-184 on Android restated — a surface outliving
/// its player — and is the likeliest cause of the one unexplained SIGSEGV
/// the spike recorded.
fn teardown(&mut self) {
if let Some(render) = self.render.take() {
if let Some(gl) = &self.gl {
gl.make_current();
}
// Unregisters the callback before freeing the context.
unsafe { render.destroy() };
}
// Only now is it safe to reclaim what the callback was holding: mpv can
// no longer reach it. Freeing it first would be the use-after-free this
// ordering exists to prevent.
if !self.callback_ctx.is_null() {
unsafe { drop(Box::from_raw(self.callback_ctx)) };
self.callback_ctx = std::ptr::null_mut();
}
self.gl = None;
}
}
/// A live video surface. Dropping it tears the render context down.
/// The widgets that make up the video surface, kept together because their
/// lifetimes are bound: the render context (added next) is created when the GL
/// area realizes and must be freed before it unrealizes — DR-232.
pub struct VideoSurface {
state: Rc<RefCell<SurfaceState>>,
widget: gtk::Box,
handlers: Vec<glib::SignalHandlerId>,
/// The GL area mpv renders into. Main child of the overlay, so it sits
/// *under* everything else.
#[allow(dead_code)]
gl_area: gtk::GLArea,
/// The overlay holding the GL area and the webview.
#[allow(dead_code)]
overlay: gtk::Overlay,
}
impl Drop for VideoSurface {
fn drop(&mut self) {
for id in self.handlers.drain(..) {
self.widget.disconnect(id);
}
self.state.borrow_mut().teardown();
self.widget.queue_draw();
info!("[VideoSurface] detached");
impl VideoSurface {
// Consumed by the render context, which binds to the GL area on `realize`
// and is freed on `unrealize` (DR-232). Held here from the moment the
// surface exists so that binding has something to attach to.
#[allow(dead_code)]
/// The GL area, for the render context to bind to.
pub fn gl_area(&self) -> &gtk::GLArea {
&self.gl_area
}
#[allow(dead_code)]
/// The overlay, for teardown.
pub fn overlay(&self) -> &gtk::Overlay {
&self.overlay
}
}
/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
/// flags the state and asks GTK to redraw on the main loop.
/// Why a surface could not be attached.
///
/// **Nothing here may block or re-enter the player.** The project's deadlock
/// gotcha applies with full force — this is called from mpv's own threads.
///
/// TRACES: UR-080 | DR-233
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
if ctx.is_null() {
return;
}
// Runs on an *mpv* thread. It therefore does exactly one thing that is safe
// to do from there: set an atomic flag.
//
// It must not touch GTK, and specifically must not schedule work with
// `idle_add_local*`, which requires the calling thread to own the default
// main context — from here that panics with "default main context already
// acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
// an `Rc` is not `Send`, and cloning one from two threads races its
// refcount.
//
// The frame clock on the widget picks the flag up on the main thread. See
// `install_frame_clock`.
let flag = &*(ctx as *const Arc<AtomicBool>);
flag.store(true, Ordering::Release);
/// One variant, because there is exactly one way this fails that is not already
/// reported by Tauri itself: the window exists and has a vbox, but the vbox is
/// not shaped the way Tauri has always shaped it.
#[derive(Debug)]
pub enum SurfaceError {
/// The vbox held no webview to reparent — Tauri's layout has changed.
NoWebviewChild,
}
/// Start drawing mpv's video underneath the webview.
///
/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
/// in. It is not modified; only a `draw` handler is added.
///
/// Must run on the GTK main thread.
///
/// TRACES: UR-080 | DR-231, DR-232, DR-233
pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
if mpv.is_null() {
warn!("[VideoSurface] no mpv handle; native video unavailable");
return false;
}
let state = Rc::new(RefCell::new(SurfaceState {
gl: None,
render: None,
mpv,
frame_ready: Arc::new(AtomicBool::new(false)),
callback_ctx: std::ptr::null_mut(),
logged_first_draw: false,
logged_first_frame: false,
logged_size: (0, 0),
logged_no_gl: false,
logged_no_window: false,
logged_no_size: false,
logged_render_fail: false,
}));
let mut handlers = Vec::new();
// The GL context can only be created once the widget has a GdkWindow, which
// is what `realize` announces. Creating it earlier leaves nothing to attach
// to — the same ordering constraint the render context has.
let realize_state = state.clone();
handlers.push(vbox.connect_realize(move |widget| {
if let Err(e) = init_gl(widget, &realize_state) {
error!("[VideoSurface] GL init failed: {e}");
}
}));
// A render context outliving its GL context is the defect DR-232 exists to
// prevent, so teardown is bound to `unrealize` rather than left to Drop.
let unrealize_state = state.clone();
handlers.push(vbox.connect_unrealize(move |_| {
unrealize_state.borrow_mut().teardown();
}));
// Drive the render loop from the widget's frame clock, on the main thread,
// rendering only when mpv actually has a frame.
//
// Both nearby mistakes were made and are worth naming, because each has a
// symptom that points somewhere else:
//
// - Waiting on mpv's update callback before rendering deadlocks. mpv does
// not progress until the client renders. The file loads, one frame
// appears, and everything stops — no picture, no audio, a spinner that
// never clears. It reads as a broken stream.
// - Rendering unconditionally every tick and reporting a swap each time
// tells mpv a frame reached the screen far more often than one did. It
// plays, and judders badly. It reads as a GPU or compositing limit.
//
// Polling `has_frame` each tick is neither.
//
// The frame clock only ticks while the widget is mapped, so this costs
// nothing when the window is hidden.
//
// TRACES: UR-080 | DR-233
let tick_state = state.clone();
vbox.add_tick_callback(move |widget, _clock| {
// Ask mpv, on the main thread, whether there is anything new. The
// update callback's flag is only a hint that something *may* have
// happened; `has_frame` is the authority, and asking it here is what
// keeps this from either deadlocking or over-presenting.
let ready = match tick_state.try_borrow() {
Ok(s) => {
s.frame_ready.swap(false, Ordering::AcqRel);
match s.render.as_ref() {
Some(render) => unsafe { render.has_frame() },
None => false,
}
}
Err(_) => false,
};
if ready {
widget.queue_draw();
}
glib::ControlFlow::Continue
});
let draw_state = state.clone();
handlers.push(vbox.connect_draw(move |widget, cr| {
draw(widget, cr, &draw_state);
// Propagate: the webview is a child and must still draw over us.
glib::Propagation::Proceed
}));
// The window is already up by the time we are called, so run the init the
// `realize` signal would have.
if vbox.is_realized() {
if let Err(e) = init_gl(vbox, &state) {
error!("[VideoSurface] GL init failed: {e}");
impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SurfaceError::NoWebviewChild => write!(
f,
"Tauri's default vbox had no child to reparent — its window layout has changed"
),
}
}
info!("[VideoSurface] attached to Tauri's vbox without reparenting");
// The surface lives as long as the window. Held in a thread-local rather
// than returned, because it owns `Rc` and GTK types and so is neither `Send`
// nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
// give up the ability to tear it down at all.
//
// Teardown does not depend on this being dropped: it is driven by the
// widget's `unrealize`, which is the signal that actually means "your GL
// context is going away" (DR-232).
LIVE_SURFACE.with(|cell| {
*cell.borrow_mut() = Some(VideoSurface {
state,
widget: vbox.clone(),
handlers,
});
});
true
}
thread_local! {
/// The one live surface, on the GTK main thread.
static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
}
impl std::error::Error for SurfaceError {}
/// Drop the live surface, if there is one. Idempotent.
/// Build the overlay and move Tauri's webview on top of it.
///
/// TRACES: UR-080 | DR-232
/// Tauri's Linux window is an `ApplicationWindow` holding a single vertical
/// `gtk::Box` (`default_vbox`), with the webview packed into it. This takes that
/// webview out, puts a `GtkGLArea` in its place inside a `GtkOverlay`, and adds
/// the webview back as the *overlay* child so it draws above.
///
/// **Must run on the GTK main thread.** Every GTK call here is main-thread-only,
/// and the caller reaches it via `run_on_main_thread`.
///
/// Ordering matters: the GL area is added as the overlay's main child *before*
/// the webview goes back, because `GtkOverlay` treats its first `add` as the
/// bottom of the stack. Adding them the other way round yields a webview with
/// video painted over it — an easy mistake with an obvious symptom.
///
/// TRACES: UR-080 | DR-231
pub fn attach(vbox: &gtk::Box) -> Result<VideoSurface, SurfaceError> {
// Tauri packs exactly one child (the webview) into the default vbox. Take it
// rather than assume its type: wry's widget is an implementation detail, and
// all this needs is "whatever Tauri put here".
let children = vbox.children();
let webview = children
.into_iter()
.next()
.ok_or(SurfaceError::NoWebviewChild)?;
let gl_area = gtk::GLArea::new();
// No depth buffer: mpv draws a flat picture into an FBO and nothing here is
// 3D. Asking for one costs memory on every resize for nothing.
gl_area.set_has_depth_buffer(false);
gl_area.set_has_stencil_buffer(false);
// Fill the overlay rather than centring at intrinsic size — the same defect
// `videoFitClass` had to fix on the webview side, where `max-w-full` only
// ever shrank and a 480p source rendered as a small box on a black screen.
gl_area.set_hexpand(true);
gl_area.set_vexpand(true);
let overlay = gtk::Overlay::new();
// Reparent. `remove` drops the container's reference, so hold one across the
// move or the widget is destroyed between the two calls.
let webview_ref = webview.clone();
vbox.remove(&webview);
overlay.add(&gl_area); // main child — the bottom of the stack
overlay.add_overlay(&webview_ref); // drawn above the video
// The webview must keep receiving input: it *is* the UI. `GtkOverlay` passes
// events to overlay children by default, so pass-through stays off — setting
// it would send clicks to the GL area, which has no controls on it.
overlay.set_overlay_pass_through(&webview_ref, false);
vbox.pack_start(&overlay, true, true, 0);
overlay.show_all();
info!("[VideoSurface] GL area attached beneath Tauri's webview");
Ok(VideoSurface { gl_area, overlay })
}
/// Put Tauri's window back the way it was found.
///
/// Not merely tidiness: the webview outlives the video surface, so if the
/// surface is torn down without returning the webview to the vbox the UI
/// disappears while the app keeps running. Mirrors [`attach`] exactly.
///
/// TRACES: UR-080 | DR-231, DR-232
// Called by the render-context teardown, which lands with DR-232. Written now,
// beside `attach`, because a reparent whose inverse is written later is a
// reparent whose inverse is written wrong.
#[allow(dead_code)]
pub fn detach() {
LIVE_SURFACE.with(|cell| {
cell.borrow_mut().take();
});
}
/// Create the GL context and the mpv render context over it.
fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
if state.borrow().render.is_some() {
return Ok(());
}
let window = widget.window().ok_or("widget has no GdkWindow")?;
let gl = window
.create_gl_context()
.map_err(|e| format!("create_gl_context: {e}"))?;
gl.realize().map_err(|e| format!("realize: {e}"))?;
gl.make_current();
let mpv = state.borrow().mpv;
let mut render =
unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
// The callback needs an owned handle that outlives this function, so a
// clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
// state itself: it is the only thing that may cross to an mpv thread. The
// pointer is kept so teardown can reclaim it — after the callback is
// unregistered, never before.
let flag = state.borrow().frame_ready.clone();
let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
let mut s = state.borrow_mut();
s.gl = Some(gl);
s.render = Some(render);
s.callback_ctx = ctx_box;
info!("[VideoSurface] GL and render context ready");
Ok(())
}
/// Draw the current frame, if there is one.
///
/// Runs *before* the children, which is what puts the picture behind the
/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
/// mean "draw nothing this pass" rather than an error — the webview then paints
/// over an untouched background, which is exactly the pre-native appearance.
fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
// Report each way of doing nothing exactly once. Without this the whole
// path is invisible: a draw handler that never runs, one that bails on a
// zero allocation, and one that renders perfectly all look identical from
// outside — and mpv stalls if frames are never consumed, so "no audio and
// it hangs" is a plausible symptom of *any* of them.
fn once(flag: &mut bool, msg: &str) {
if !*flag {
*flag = true;
warn!("[VideoSurface] not drawing: {msg}");
pub fn detach(vbox: &gtk::Box, surface: &VideoSurface) {
let children = surface.overlay.children();
for child in children {
// Everything except the GL area came from the vbox and goes back to it.
if child.downcast_ref::<gtk::GLArea>().is_some() {
continue;
}
surface.overlay.remove(&child);
vbox.pack_start(&child, true, true, 0);
}
vbox.remove(&surface.overlay);
vbox.show_all();
warn!("[VideoSurface] detached; webview returned to Tauri's vbox");
}
let Ok(mut s) = state.try_borrow_mut() else {
return;
};
if !s.logged_first_draw {
s.logged_first_draw = true;
info!("[VideoSurface] draw handler running");
}
let Some(gl) = s.gl.clone() else {
let f = &mut s.logged_no_gl;
once(f, "no GL context");
return;
};
let Some(window) = widget.window() else {
let f = &mut s.logged_no_window;
once(f, "widget has no GdkWindow");
return;
};
#[cfg(test)]
mod tests {
//! These exercise GTK widget wiring, so they need a display and are ignored
//! by default — CI has no X11 or Wayland session. Run locally with
//! `cargo test -- --ignored video_surface`.
let scale = widget.scale_factor();
let width = widget.allocated_width() * scale;
let height = widget.allocated_height() * scale;
if width <= 0 || height <= 0 {
let f = &mut s.logged_no_size;
once(f, "zero allocation");
return;
}
use super::*;
gl.make_current();
/// The stacking order is the whole point, and getting it backwards produces
/// video painted over the controls rather than under them.
///
/// TRACES: UR-080 | DR-231
#[test]
#[ignore = "requires a display"]
fn test_gl_area_is_below_the_reparented_webview() {
if gtk::init().is_err() {
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
// Stand in for the webview; `attach` deliberately does not care what it is.
let stand_in = gtk::DrawingArea::new();
vbox.pack_start(&stand_in, true, true, 0);
// Render and end the mutable borrow before touching the latches again.
let rendered = match s.render.as_mut() {
Some(render) => unsafe { render.render(width, height) },
None => return,
};
let Some(texture) = rendered else {
let f = &mut s.logged_render_fail;
once(f, "mpv render produced no texture");
return;
};
// Log the first frame, and again whenever the target size changes. Latching
// this once per session hid the case that matters: a second file, rendered
// at a different size, in a window that never moved. "The picture is a small
// box in the middle" and "the picture fills the widget" are indistinguishable
// from outside without it.
if !s.logged_first_frame || s.logged_size != (width, height) {
s.logged_first_frame = true;
s.logged_size = (width, height);
// The allocation *origin* matters as much as its size. A GtkBox is a
// no-window widget, so `widget.window()` is the parent's GdkWindow and
// the box sits at an offset inside it. `draw_from_gl` composites into
// that window; if it does not honour the cairo translation GTK applied
// for this widget, the picture lands at the window origin instead of
// the widget's — misaligned by exactly this offset, which is the shape
// of a letterbox that does not line up.
let alloc = widget.allocation();
info!(
"[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
alloc.x(),
alloc.y()
let surface = attach(&vbox).expect("attaches");
let children = surface.overlay().children();
// GtkOverlay lists its main child first.
assert!(
children[0].downcast_ref::<gtk::GLArea>().is_some(),
"the GL area must be the overlay's main child, i.e. underneath"
);
assert!(
children.len() > 1,
"the reparented widget must still be present"
);
}
unsafe {
cr.draw_from_gl(
&window,
texture as i32,
GL_TEXTURE,
scale,
0,
0,
width,
height,
);
// Tell mpv the frame reached the screen. Without this it has nothing to
// pace against — see DR-233.
if let Some(render) = s.render.as_ref() {
render.report_swap();
/// A surface that tears down without returning the webview leaves a running
/// app with no UI.
///
/// TRACES: UR-080 | DR-231, DR-232
#[test]
#[ignore = "requires a display"]
fn test_detach_returns_the_webview_to_the_vbox() {
if gtk::init().is_err() {
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
let stand_in = gtk::DrawingArea::new();
vbox.pack_start(&stand_in, true, true, 0);
let surface = attach(&vbox).expect("attaches");
detach(&vbox, &surface);
let children = vbox.children();
assert_eq!(children.len(), 1, "exactly the original child comes back");
assert!(
children[0].downcast_ref::<gtk::DrawingArea>().is_some(),
"and it is the webview stand-in, not the overlay"
);
}
/// A vbox Tauri has not populated is a changed assumption, not a panic.
///
/// TRACES: UR-080 | DR-231
#[test]
#[ignore = "requires a display"]
fn test_an_empty_vbox_is_an_error_not_a_panic() {
if gtk::init().is_err() {
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
assert!(matches!(attach(&vbox), Err(SurfaceError::NoWebviewChild)));
}
}
-197
View File
@@ -1,197 +0,0 @@
//! Multi-user profiles on one device.
//!
//! A "profile" is an account on the *currently connected* Jellyfin server that
//! this device has signed into at least once. The rows have existed since the
//! first schema (`users`), and so have `storage_get_users` /
//! `storage_set_active_user`; what was missing was never the storage but the
//! decision of who may switch to what — which is domain logic, and stays here.
//!
//! Two things this module is careful about:
//!
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
//! endpoint, which invalidates the token server-side. That is precisely the
//! behaviour a switch must not have, or every switch back would need a
//! password. Nothing here calls it.
//! - **"Child account" is not modelled.** A child's profile is simply one with
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
//! never infers a role, and no role taxonomy is invented on either side.
//!
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
pub mod pin;
pub mod store;
pub mod switch;
use serde::{Deserialize, Serialize};
/// How a profile is entered.
///
/// Deliberately not "adult"/"child": the app has no way to know a person's age
/// and no business encoding one. It knows whether a code is set.
///
/// TRACES: UR-083 | DR-276
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UnlockMethod {
/// One tap. No code set.
None,
/// A numeric code gates the switch.
Pin,
}
/// A switchable account on this device.
///
/// TRACES: UR-082 | DR-267
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub user_id: String,
pub username: String,
pub server_id: String,
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
pub avatar_tag: Option<String>,
pub unlock_method: UnlockMethod,
pub last_used_at: Option<String>,
pub is_active: bool,
}
/// The result of an unlock attempt.
///
/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
/// with their Rust names rather than camelCasing them, so a field that would
/// differ between the two conventions is renamed here by hand — the same trap
/// that produced `new_url` on the frontend once already.
///
/// TRACES: UR-083, UR-084 | DR-268, DR-269
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UnlockOutcome {
/// Switched. The profile is now active.
Ok {
#[serde(rename = "userId")]
user_id: String,
},
/// Wrong code, attempts left.
WrongPin {
#[serde(rename = "attemptsRemaining")]
attempts_remaining: u32,
},
/// Too many wrong codes; refused until this RFC3339 instant.
LockedOut { until: String },
/// No code is recoverable from here — sign in with the account password.
NeedsPassword,
}
/// What the app should do when it starts.
///
/// The decision is backend state (profile count, PIN presence, a stored
/// setting), so the frontend asks rather than computes. A single account with no
/// PIN always resumes, which is what keeps this feature invisible until it is
/// wanted.
///
/// TRACES: UR-082 | DR-274
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StartupTarget {
/// Resume this profile without asking.
Resume {
#[serde(rename = "userId")]
user_id: String,
},
/// Show the picker.
Picker,
}
/// Decide the startup target from the profiles present and the user's setting.
///
/// Pure, because the rule is worth testing and the inputs are trivial to state:
///
/// - No profiles at all → picker (which renders as the first-run login).
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
/// that could be skipped by relaunching is not a code.
/// - More than one profile and "ask who's watching" is on → picker.
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
///
/// TRACES: UR-082 | DR-274
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
let last_used = profiles
.iter()
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
match last_used {
None => StartupTarget::Picker,
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
Some(p) => StartupTarget::Resume {
user_id: p.user_id.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
Profile {
user_id: id.to_string(),
username: id.to_string(),
server_id: "server-1".to_string(),
avatar_tag: None,
unlock_method: unlock,
last_used_at: last_used.map(|s| s.to_string()),
is_active: false,
}
}
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
#[test]
fn single_pinless_profile_resumes() {
let profiles = vec![profile(
"u1",
UnlockMethod::None,
Some("2026-01-01T00:00:00Z"),
)];
assert_eq!(
startup_target(&profiles, true),
StartupTarget::Resume {
user_id: "u1".to_string()
},
"a lone profile resumes even with the setting on"
);
}
/// UT: a PIN is not skippable by relaunching the app.
#[test]
fn pinned_last_profile_always_asks() {
let profiles = vec![profile(
"u1",
UnlockMethod::Pin,
Some("2026-01-01T00:00:00Z"),
)];
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
}
/// UT: several pinless profiles resume the last one unless asked to ask.
#[test]
fn multiple_profiles_follow_the_setting() {
let profiles = vec![
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
];
assert_eq!(
startup_target(&profiles, false),
StartupTarget::Resume {
user_id: "u2".to_string()
},
"resumes the most recently used"
);
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
}
/// UT: nothing signed in yet.
#[test]
fn no_profiles_shows_the_picker() {
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
}
}
-290
View File
@@ -1,290 +0,0 @@
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
//!
//! The policy half is deliberately pure — it takes the stored counter state and
//! the current time, and returns the decision plus the next state. That is what
//! makes "five wrong guesses then a lockout that survives a restart" testable
//! without a database, a clock, or a running app.
//!
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
//! it does not encrypt that profile's access token, so anyone holding the
//! database and the keyring has every token regardless. That trade is deliberate
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
//! profile unable to resume its own downloads or drain its own sync queue until
//! somebody walked past and typed the code.
//!
//! TRACES: UR-083 | DR-268
use argon2::Argon2;
use chrono::{DateTime, Duration, Utc};
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
/// Wrong guesses allowed before the first lockout.
pub const MAX_ATTEMPTS: u32 = 5;
/// How long the first lockout lasts. Each subsequent failure doubles it.
const BASE_LOCKOUT_SECS: i64 = 60;
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
/// password route is always there, and a lockout measured in hours would push
/// people towards not setting a PIN at all.
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
/// Persisted counter state for one profile's PIN.
///
/// TRACES: UR-083 | DR-268
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinState {
pub failed_count: u32,
pub locked_until: Option<DateTime<Utc>>,
}
impl PinState {
pub fn fresh() -> Self {
Self {
failed_count: 0,
locked_until: None,
}
}
}
/// What the caller should do with an attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinDecision {
Accept,
Reject { attempts_remaining: u32 },
Locked { until: DateTime<Utc> },
}
/// Decide an attempt and produce the state to persist.
///
/// `pin_matches` is the result of the hash comparison; passing it in rather than
/// doing the comparison here is what keeps this function pure and cheap to test
/// across the whole attempt/lockout space.
///
/// A locked profile is refused *without consulting the hash*, so a caller cannot
/// burn through a lockout by guessing quickly.
///
/// TRACES: UR-083 | DR-268
pub fn evaluate(
state: &PinState,
now: DateTime<Utc>,
pin_matches: bool,
) -> (PinDecision, PinState) {
if let Some(until) = state.locked_until {
if now < until {
return (PinDecision::Locked { until }, state.clone());
}
}
if pin_matches {
return (PinDecision::Accept, PinState::fresh());
}
let failed_count = state.failed_count.saturating_add(1);
if failed_count >= MAX_ATTEMPTS {
let over = i64::from(failed_count - MAX_ATTEMPTS);
let secs = BASE_LOCKOUT_SECS
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
.min(MAX_LOCKOUT_SECS);
let until = now + Duration::seconds(secs);
(
PinDecision::Locked { until },
PinState {
failed_count,
locked_until: Some(until),
},
)
} else {
(
PinDecision::Reject {
attempts_remaining: MAX_ATTEMPTS - failed_count,
},
PinState {
failed_count,
locked_until: None,
},
)
}
}
/// A PIN must be 48 digits. Rejecting non-digits here rather than in the pad
/// keeps the rule where the rule is enforced.
///
/// TRACES: UR-083 | DR-268
pub fn validate_pin(pin: &str) -> Result<(), String> {
if pin.len() < 4 || pin.len() > 8 {
return Err("PIN must be between 4 and 8 digits".to_string());
}
if !pin.chars().all(|c| c.is_ascii_digit()) {
return Err("PIN must contain only digits".to_string());
}
Ok(())
}
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
///
/// TRACES: UR-083 | DR-268
pub fn hash_pin(pin: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pin.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| format!("Failed to hash PIN: {}", e))
}
/// Compare a candidate PIN against a stored PHC string.
///
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
/// row should send the user down the password route, not wedge the picker.
///
/// TRACES: UR-083 | DR-268
pub fn verify_pin(pin: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(pin.as_bytes(), &parsed)
.is_ok(),
Err(e) => {
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
/// UT: a correct PIN is accepted and clears any accumulated failures.
#[test]
fn correct_pin_accepts_and_resets() {
let state = PinState {
failed_count: 3,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: wrong guesses count down, and the count is what gets persisted —
/// this is the half that must survive an app restart.
#[test]
fn wrong_pin_counts_down() {
let mut state = PinState::fresh();
for expected in (1..MAX_ATTEMPTS).rev() {
let (decision, next) = evaluate(&state, t0(), false);
assert_eq!(
decision,
PinDecision::Reject {
attempts_remaining: expected
}
);
state = next;
}
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
}
/// UT: the attempt that exhausts the allowance locks out rather than
/// reporting zero attempts remaining.
#[test]
fn exhausting_attempts_locks_out() {
let state = PinState {
failed_count: MAX_ATTEMPTS - 1,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), false);
match decision {
PinDecision::Locked { until } => {
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
}
other => panic!("expected lockout, got {:?}", other),
}
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
}
/// UT: a locked profile is refused without the hash being consulted — the
/// correct PIN does not shortcut an active lockout.
#[test]
fn lockout_refuses_even_a_correct_pin() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Locked { until });
assert_eq!(next, state, "a refused attempt must not extend the lockout");
}
/// UT: once the window passes the profile accepts again.
#[test]
fn lockout_expires() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
/// PIN never becomes an hours-long wait.
#[test]
fn lockout_escalates_to_a_ceiling() {
let mut seen = Vec::new();
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
let state = PinState {
failed_count: failed,
locked_until: None,
};
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
seen.push((until - t0()).num_seconds());
}
}
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
assert!(seen[1] > seen[0], "second lockout should be longer");
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
}
/// UT: hashing round-trips, and a wrong PIN does not verify.
#[test]
fn hash_round_trips() {
let hash = hash_pin("1234").unwrap();
assert!(verify_pin("1234", &hash));
assert!(!verify_pin("4321", &hash));
}
/// UT: the stored hash never contains the PIN itself.
#[test]
fn hash_does_not_leak_the_pin() {
let hash = hash_pin("246813").unwrap();
assert!(!hash.contains("246813"));
}
/// UT: an unreadable stored hash fails closed instead of erroring, so a
/// corrupt row sends the user to the password route.
#[test]
fn corrupt_hash_fails_closed() {
assert!(!verify_pin("1234", "not-a-phc-string"));
}
/// UT: PIN shape is enforced in Rust, not in the pad.
#[test]
fn pin_shape_is_validated() {
assert!(validate_pin("1234").is_ok());
assert!(validate_pin("12345678").is_ok());
assert!(validate_pin("123").is_err(), "too short");
assert!(validate_pin("123456789").is_err(), "too long");
assert!(validate_pin("12a4").is_err(), "non-digit");
}
}
-206
View File
@@ -1,206 +0,0 @@
//! Database access for profiles.
//!
//! Everything here takes an explicit `user_id`. There is no ambient "current
//! user" in this module — the caller has to say who it means, which is what
//! stops a switch half-applying and writing one profile's state under another's
//! id.
//!
//! TRACES: UR-082, UR-083 | DR-267, DR-268
use std::sync::Arc;
use chrono::{DateTime, Utc};
use super::pin::PinState;
use super::{Profile, UnlockMethod};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// List every profile known for a server, most recently used first.
///
/// A profile's unlock method is derived from the presence of a `user_pins` row
/// rather than stored twice, so the two can never disagree.
///
/// TRACES: UR-082 | DR-267
pub async fn list_profiles(
db: &Arc<RusqliteService>,
server_id: &str,
) -> Result<Vec<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.server_id = ?
ORDER BY u.last_login_at DESC",
vec![QueryParam::String(server_id.to_string())],
);
db.query_many(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// Fetch a single profile, or `None` if this device does not know it.
///
/// TRACES: UR-082 | DR-267
pub async fn get_profile(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.query_optional(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
///
/// TRACES: UR-083 | DR-268
pub async fn get_pin(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<(String, PinState)>, String> {
let query = Query::with_params(
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
let row: Option<(String, i64, Option<String>)> = db
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
Ok(row.map(|(hash, failed, locked)| {
let locked_until = locked
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
(
hash,
PinState {
failed_count: failed.max(0) as u32,
locked_until,
},
)
}))
}
/// Store (or replace) a profile's PIN, resetting its counters.
///
/// TRACES: UR-083 | DR-268
pub async fn set_pin(
db: &Arc<RusqliteService>,
user_id: &str,
pin_hash: &str,
) -> Result<(), String> {
let query = Query::with_params(
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
pin_hash = excluded.pin_hash,
failed_count = 0,
locked_until = NULL,
updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(user_id.to_string()),
QueryParam::String(pin_hash.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Remove a profile's PIN, making it a one-tap profile.
///
/// TRACES: UR-083 | DR-268
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
let query = Query::with_params(
"DELETE FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Persist the counter state produced by [`super::pin::evaluate`].
///
/// This is what makes a lockout survive a restart: the deadline is on disk, not
/// in a process-lifetime counter that closing the app would clear.
///
/// TRACES: UR-083 | DR-268
pub async fn save_pin_state(
db: &Arc<RusqliteService>,
user_id: &str,
state: &PinState,
) -> Result<(), String> {
let locked = match state.locked_until {
Some(dt) => QueryParam::String(dt.to_rfc3339()),
None => QueryParam::Null,
};
let query = Query::with_params(
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
vec![
QueryParam::Int64(i64::from(state.failed_count)),
locked,
QueryParam::String(user_id.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
///
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
/// from this device is a local act; invalidating a token the person may be using
/// on their phone is not what "remove from this TV" means. The caller deletes the
/// stored token separately.
///
/// TRACES: UR-082 | DR-267
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
// user_libraries, download_grants and the rest; the users row is the root.
let query = Query::with_params(
"DELETE FROM users WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
-225
View File
@@ -1,225 +0,0 @@
//! Profile switch orchestration, as a plan rather than a procedure.
//!
//! Switching profiles tears down and rebuilds nearly everything the app holds:
//! the player and its queue, the sync queue drain, the session poller, the
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
//! is a correctness invariant, not an implementation detail — a straggler that
//! reports after the active user has flipped attributes one account's viewing to
//! another, which is silent, plausible-looking, and unrecoverable.
//!
//! So the ordering lives here as a pure function returning a list of steps, and
//! the command layer executes them. That is the only way this gets tested: an
//! end-to-end switch needs two real accounts on a real server, which CI does not
//! have and never will. The plan needs nothing.
//!
//! Two hazards worth remembering while executing a plan, both already paid for
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
//! player event callback, and never hold a lock across a `match` scrutinee. A
//! teardown reaches every one of those paths at once, from a new direction.
//!
//! TRACES: UR-082 | DR-270
/// One executable step of a switch.
///
/// TRACES: UR-082 | DR-270
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SwitchStep {
/// Stop playback and drop the queue. The queue cannot outlive its owner.
StopPlayback,
/// Flush what the outgoing profile changed while offline, so it is not
/// replayed under the incoming profile's token.
ParkSyncQueue {
user_id: String,
},
StopSessionPoller,
/// Clear OS media metadata so the lockscreen does not show the outgoing
/// profile's episode to whoever just took over the device.
ClearLockscreenMetadata,
DestroyRepository,
/// The point of no return: after this, writes land under the new profile.
SetActiveUser {
user_id: String,
},
BuildRepository {
user_id: String,
},
StartSessionPoller,
/// Re-derive what the server currently lets this profile see. Only possible
/// online; offline the cached view stays as it was, which is stale-permissive
/// by design.
RefreshVisibility {
user_id: String,
},
EmitSwitched {
user_id: String,
},
}
/// Build the ordered plan for moving from `from` to `to`.
///
/// Switching to the profile that is already active is not a no-op — it is how an
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
/// your own screen would stop the music. Only the emit survives.
///
/// TRACES: UR-082 | DR-270
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
if from == Some(to) {
return vec![SwitchStep::EmitSwitched {
user_id: to.to_string(),
}];
}
let mut steps = Vec::new();
if let Some(outgoing) = from {
steps.push(SwitchStep::StopPlayback);
steps.push(SwitchStep::ParkSyncQueue {
user_id: outgoing.to_string(),
});
steps.push(SwitchStep::StopSessionPoller);
steps.push(SwitchStep::ClearLockscreenMetadata);
steps.push(SwitchStep::DestroyRepository);
}
steps.push(SwitchStep::SetActiveUser {
user_id: to.to_string(),
});
steps.push(SwitchStep::BuildRepository {
user_id: to.to_string(),
});
steps.push(SwitchStep::StartSessionPoller);
if online {
steps.push(SwitchStep::RefreshVisibility {
user_id: to.to_string(),
});
}
steps.push(SwitchStep::EmitSwitched {
user_id: to.to_string(),
});
steps
}
#[cfg(test)]
mod tests {
use super::*;
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
steps
.iter()
.position(|s| s == want)
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
}
/// UT: the invariant that prevents misattributed playback reports —
/// everything belonging to the outgoing profile is torn down *before* the
/// active user flips.
#[test]
fn teardown_precedes_the_flip() {
let steps = plan(Some("dad"), "kid", true);
let flip = index_of(
&steps,
&SwitchStep::SetActiveUser {
user_id: "kid".to_string(),
},
);
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
assert!(
index_of(
&steps,
&SwitchStep::ParkSyncQueue {
user_id: "dad".to_string()
}
) < flip
);
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
}
/// UT: the outgoing profile's queued offline mutations are parked under
/// *its* id, never the incoming one's.
#[test]
fn sync_queue_is_parked_for_the_outgoing_profile() {
let steps = plan(Some("dad"), "kid", true);
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
user_id: "dad".to_string()
}));
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
user_id: "kid".to_string()
}));
}
/// UT: the repository is rebuilt only after the flip, so it cannot be
/// constructed against a user id that is about to change.
#[test]
fn repository_is_rebuilt_after_the_flip() {
let steps = plan(Some("dad"), "kid", true);
let flip = index_of(
&steps,
&SwitchStep::SetActiveUser {
user_id: "kid".to_string(),
},
);
assert!(
index_of(
&steps,
&SwitchStep::BuildRepository {
user_id: "kid".to_string()
}
) > flip
);
}
/// UT: the switch is announced last, so nothing observing the event can
/// catch the app mid-teardown.
#[test]
fn switch_is_announced_last() {
let steps = plan(Some("dad"), "kid", true);
assert_eq!(
steps.last(),
Some(&SwitchStep::EmitSwitched {
user_id: "kid".to_string()
})
);
}
/// UT: first sign-in has nothing to tear down.
#[test]
fn cold_start_only_builds_up() {
let steps = plan(None, "kid", true);
assert!(!steps.contains(&SwitchStep::StopPlayback));
assert!(!steps.contains(&SwitchStep::DestroyRepository));
assert_eq!(
steps.first(),
Some(&SwitchStep::SetActiveUser {
user_id: "kid".to_string()
})
);
}
/// UT: offline, visibility cannot be re-derived — the server is not there to
/// say what this profile may see, and guessing would be worse than stale.
#[test]
fn offline_skips_visibility_refresh() {
let steps = plan(Some("dad"), "kid", false);
assert!(!steps
.iter()
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
}
/// UT: dismissing an idle re-lock on your own profile must not stop the
/// music you were listening to.
#[test]
fn unlocking_the_same_profile_does_not_disturb_playback() {
let steps = plan(Some("dad"), "dad", true);
assert_eq!(
steps,
vec![SwitchStep::EmitSwitched {
user_id: "dad".to_string()
}]
);
}
}
+8 -129
View File
@@ -684,33 +684,26 @@ impl OfflineRepository {
/// The position half is what makes cross-device resume work: the resume
/// check reads this table alone, so before it was mirrored an item watched
/// elsewhere resumed from whatever *this* device last saw, or not at all.
/// The played flag rides along for the same reason: nothing else writes it
/// but an explicit local toggle, so a cached episode list read every
/// episode back as unwatched — the list the season view ticks and the one
/// `pick_current_episode` reads to decide what is up next (DR-264).
///
/// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
let user_data = item.user_data.as_ref();
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
let is_played = user_data.and_then(|ud| ud.is_played);
// Nothing the server actually told us about — do not invent a row.
if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
if is_favorite.is_none() && position_ticks.is_none() {
return Ok(());
}
let query = Query::with_params(
"INSERT INTO user_data
(user_id, item_id, is_favorite, playback_position_ticks, is_played,
synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
playback_position_ticks = COALESCE(
excluded.playback_position_ticks, user_data.playback_position_ticks),
is_played = COALESCE(excluded.is_played, user_data.is_played),
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0",
vec![
@@ -722,9 +715,6 @@ impl OfflineRepository {
position_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
is_played
.map(|p| QueryParam::Int(if p { 1 } else { 0 }))
.unwrap_or(QueryParam::Null),
QueryParam::String(now.to_string()),
],
);
@@ -1249,29 +1239,10 @@ impl MediaRepository for OfflineRepository {
let start_index = opts.start_index.unwrap_or(0);
// SortBy=Random is the only sort the landing pages rely on offline (the
// hero "surprise" pool); PremiereDate is what a channel folder's
// children are listed by (DR-257), so the cached leg of the race agrees
// with the server's order instead of flashing a name-sorted list first.
// Everything else keeps the stable name order.
//
// Rows with no premiere date sort last rather than leading the list.
let default_sort = default_listing_sort(opts.parent_kind);
let sort_field = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let descending = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order))
== Some("Descending");
let order_by = match sort_field {
Some("Random") => "RANDOM()".to_string(),
Some("PremiereDate") => format!(
"i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
if descending { "DESC" } else { "ASC" }
),
_ => "i.sort_name ASC, i.name ASC".to_string(),
// hero "surprise" pool); everything else keeps the stable name order.
let order_by = match opts.sort_by.as_deref() {
Some("Random") => "RANDOM()",
_ => "i.sort_name ASC, i.name ASC",
};
// Bind the type filter rather than interpolating it: `include_item_types`
@@ -4796,98 +4767,6 @@ mod tests {
);
}
/// UT-240 — the server's *played* flag is mirrored locally, so an episode
/// watched anywhere is watched here.
///
/// The mirror carried only the favourite flag and the position, so
/// `user_data.is_played` was written by nothing but an explicit local
/// toggle: a cached episode list reported every episode as unwatched, which
/// is the list `pick_current_episode` reads to decide what is up next
/// (DR-264), and the list the season view ticks.
///
/// TRACES: UR-025, UR-062 | DR-264 | UT-240
#[tokio::test]
async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let played_flag = |id: &'static str| {
let db = db_service.clone();
async move {
db.query_optional(
Query::with_params(
"SELECT is_played, pending_sync FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String(id.to_string()),
],
),
|row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
)
.await
.unwrap()
}
};
// Watched to the end on another client.
let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
watched.user_data = Some(UserData {
is_played: Some(true),
..Default::default()
});
// No user data at all — must not fabricate an "unwatched" record.
let untouched = create_test_item("ep-5", "No User Data", None);
repo.save_to_cache("parent-1", &[watched, untouched])
.await
.unwrap();
assert_eq!(
played_flag("ep-4").await,
Some((Some(1), Some(0))),
"the server's played flag should be mirrored as synced"
);
assert_eq!(
played_flag("ep-5").await,
None,
"an item without UserData should not get an invented played flag"
);
// Marked unwatched here while the server was unreachable.
db_service
.execute(Query::with_params(
"UPDATE user_data SET is_played = 0, pending_sync = 1 \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-4".to_string()),
],
))
.await
.unwrap();
let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
still_played.user_data = Some(UserData {
is_played: Some(true),
..Default::default()
});
repo.save_to_cache("parent-1", &[still_played])
.await
.unwrap();
assert_eq!(
played_flag("ep-4").await,
Some((Some(0), Some(1))),
"an unsynced local toggle must survive a cache write"
);
}
/// UT-152 — a server item carrying *only* a position (no favourite flag)
/// still gets mirrored.
///
+6 -378
View File
@@ -1230,22 +1230,7 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
}
// An explicit sort always wins; the container's default only fills the
// gap when the caller named none. A caller that names neither gets no
// SortBy at all, leaving the server's own order intact.
//
// TRACES: UR-007 | DR-257 | UT-229
let default_sort = default_listing_sort(opts.parent_kind);
let sort_by = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let sort_order = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order));
if let Some(sort_by) = sort_by {
if let Some(sort_by) = &opts.sort_by {
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
let encoded: Vec<String> = sort_by
@@ -1254,7 +1239,7 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
}
if let Some(sort_order) = sort_order {
if let Some(sort_order) = &opts.sort_order {
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
}
if let Some(recursive) = opts.recursive {
@@ -1306,117 +1291,6 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
)
}
/// How many rows to ask the server for, given how many the row will show.
///
/// Collapsing only ever shrinks a listing, so a request for exactly the number
/// of cards the row shows can come back as a handful after one freshly-ripped
/// album folds its tracks together. Over-fetch and truncate after collapsing.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
fn latest_items_fetch_limit(limit: usize) -> usize {
limit.saturating_mul(3)
}
/// Collapse newly-added *tracks* into the album they belong to.
///
/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
/// ignore the parameter outright — so "Recently Added" still filled up with one
/// card per song of a single import. Grouping again here makes the row's shape
/// a property of this app rather than of the server it is talking to.
///
/// Rules: a track collapses only when it names an `album_id` (without one there
/// is no album to open, so a standalone track stays a track); if the server did
/// return the album row itself, that row wins and its tracks are dropped; the
/// album takes the position of the first of its tracks, so recency order
/// survives. Everything else — movies, episodes, folders — passes through
/// untouched.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
use std::collections::HashSet;
// Albums the server already returned in their own right: their tracks are
// redundant, and the real row carries detail a stand-in cannot.
let server_albums: HashSet<String> = items
.iter()
.filter(|i| i.kind == crate::domain::MediaKind::Album)
.map(|i| i.id.clone())
.collect();
let mut seen_albums: HashSet<String> = HashSet::new();
let mut collapsed = Vec::with_capacity(items.len());
for item in items {
let album_id = match (&item.kind, &item.album_id) {
(crate::domain::MediaKind::Track, Some(id)) => id.clone(),
_ => {
collapsed.push(item);
continue;
}
};
if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
continue;
}
collapsed.push(album_from_track(&item, album_id));
}
collapsed
}
/// Build the album card a collapsed group of tracks stands for.
///
/// The track's own artwork tag is reused: Jellyfin serves an item's primary
/// image by id and treats the tag as a cache key, and an embedded-art track
/// carries the album cover anyway.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
MediaItem {
id: album_id,
name: track
.album_name
.clone()
.unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
kind: crate::domain::MediaKind::Album,
is_folder: true,
server_id: track.server_id.clone(),
parent_id: None,
library_id: track.library_id.clone(),
overview: None,
genres: track.genres.clone(),
production_year: track.production_year,
premiere_date: track.premiere_date.clone(),
community_rating: None,
official_rating: None,
// A track's duration says nothing about the album's, and its track
// number, album link and streams belong to the leaf alone.
runtime_ticks: None,
duration_ms: None,
primary_image_tag: track.primary_image_tag.clone(),
image_id: track.image_id.clone(),
backdrop_image_tags: track.backdrop_image_tags.clone(),
parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
album_id: None,
album_name: None,
album_artist: track.album_artist.clone(),
artists: track.artists.clone(),
artist_items: track.artist_items.clone(),
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: None,
media_streams: None,
media_sources: None,
people: None,
}
}
/// Build the Jellyfin endpoint for a Next Up listing.
///
/// `EnableResumable=false` is the point of this query: the server default is
@@ -1866,34 +1740,18 @@ impl MediaRepository for OnlineRepository {
Ok(media_item)
}
/// Recently Added, one card per thing that was added.
///
/// The server is asked to group (`GroupItems=true`) *and* the answer is
/// grouped again here — see `collapse_tracks_into_albums` for why trusting
/// the server alone left the row full of one album's songs.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
async fn get_latest_items(
&self,
parent_id: &str,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(16);
let endpoint = build_latest_items_endpoint(
&self.user_id,
parent_id,
Some(latest_items_fetch_limit(limit_val)),
);
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
let items = items
Ok(items
.into_iter()
.map(|item| item.into_media_item(self.user_id.clone()))
.collect();
let mut collapsed = collapse_tracks_into_albums(items);
collapsed.truncate(limit_val);
Ok(collapsed)
.collect())
}
/// Continue Watching: the items this user has started and not finished.
@@ -2596,15 +2454,8 @@ impl MediaRepository for OnlineRepository {
stream_index: i32,
format: &str,
) -> String {
// `Stream.{format}` is the route, not a filename we get to choose:
// Jellyfin exposes the subtitle as
// `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`, and
// stopping at the format alone matches no route and 404s. Every
// sideloaded subtitle failed to load on Android because of it, leaving
// ExoPlayer with no text tracks to select.
// TRACES: UR-020 | JA-008, DR-259 | UT-234
format!(
"{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
"{}/Videos/{}/{}/Subtitles/{}/{}",
self.server_url, item_id, media_source_id, stream_index, format
)
}
@@ -3137,7 +2988,6 @@ impl MediaRepository for OnlineRepository {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::MediaKind;
use crate::utils::lock::MutexSafe;
use std::sync::Arc;
@@ -3153,35 +3003,6 @@ mod tests {
)
}
/// The reported bug: on Android every subtitle track was inert — the menu
/// listed 42 languages and picking one changed nothing.
///
/// The cause is here rather than in the player. ExoPlayer sideloads each
/// subtitle as its own media source, and since media3 1.5 a sideloaded text
/// track only becomes a *track group* once its file has been fetched and
/// parsed. Every fetch 404ed, so `Tracks` carried no text group at all and
/// `setSubtitleTrack(1)` warned `available: 0` and dropped the request.
///
/// Jellyfin's route is `/Videos/{item}/{source}/Subtitles/{index}/Stream.{fmt}`
/// (verified against a live server: this shape answers 200, the one built
/// here answered 404). The `Stream.` segment is not decoration — without it
/// the path matches no route.
///
/// The old mock-based URL tests could not catch this: they asserted the
/// shape of a *test helper* that duplicated the format string, not of the
/// URL the app actually requests.
///
/// TRACES: UR-020 | JA-008, DR-259 | UT-234
#[test]
fn subtitle_url_uses_jellyfins_stream_route() {
let repo = create_test_repository();
assert_eq!(
repo.get_subtitle_url("item123", "source456", 2, "vtt"),
"https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
);
}
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
@@ -4166,70 +3987,6 @@ mod tests {
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
}
/// The reported bug: a Jellypod podcast listed its episodes alphabetically,
/// so "[Played] …" titles clumped at the top and a new episode landed
/// wherever its name happened to fall.
///
/// The cause was the frontend asking for `SortBy=SortName` on *every*
/// drill-down, which overrides the order the channel plugin itself would
/// have returned. Which order a container's children take is domain
/// knowledge, so the caller now names the container and the repository
/// answers with the sort: a channel folder is release-date-newest-first,
/// everything else keeps the name order it had.
///
/// TRACES: UR-007 | DR-257 | UT-229
#[test]
fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
let podcast = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
..Default::default()
}),
);
assert!(
podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
"{podcast}"
);
// Every other container keeps the name order the app has always used.
let season = build_get_items_endpoint(
"u1",
"season-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::Season),
..Default::default()
}),
);
assert!(
season.contains("&SortBy=SortName&SortOrder=Ascending"),
"{season}"
);
// An explicit sort still wins — the default only fills a gap.
let explicit = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
sort_by: Some("SortName".to_string()),
sort_order: Some("Ascending".to_string()),
..Default::default()
}),
);
assert!(
explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
"{explicit}"
);
assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
// A caller that names no container is left alone, so the paths that
// rely on the server's own order (a playlist's stored order) keep it.
let unspecified = build_get_items_endpoint("u1", "lib-1", None);
assert!(!unspecified.contains("SortBy="), "{unspecified}");
}
/// A newly-added album must arrive as one entry, not one per track.
///
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
@@ -4249,135 +4006,6 @@ mod tests {
assert!(endpoint.contains("Limit=16"));
}
/// Build a `MediaItem` the way a real listing does — through the Jellyfin
/// payload — so the fixtures cannot drift from the parsed shape.
fn item_from_json(json: &str) -> MediaItem {
let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
parsed.into_media_item("srv".to_string())
}
fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
let album = match album_id {
Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
None => String::new(),
};
item_from_json(&format!(
r#"{{
"Id": "{id}",
"Name": "{name}",
"Type": "Audio",
{album}
"ImageTags": {{"Primary": "art-{id}"}},
"AlbumArtist": "Miles Davis",
"Artists": ["Miles Davis"],
"IndexNumber": 1,
"RunTimeTicks": 1000
}}"#
))
}
/// A newly-imported album must read as *one* new album, not one new song
/// per track — even when the server hands back the raw leaves despite
/// `GroupItems=true` (older servers, and libraries whose tracks resolve no
/// album parent, ignore it).
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
#[test]
fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
let movie = item_from_json(
r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
);
let items = vec![
track("trk-1", "So What", Some("alb-1")),
track("trk-2", "Blue in Green", Some("alb-1")),
movie,
track("trk-3", "Flamenco Sketches", Some("alb-1")),
];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(
collapsed.len(),
2,
"three tracks of one album plus a movie must read as two cards, got: {:?}",
collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
);
let album = &collapsed[0];
assert_eq!(album.id, "alb-1", "the card must open the album");
assert_eq!(album.name, "Kind of Blue");
assert_eq!(album.item_type, "MusicAlbum");
assert_eq!(album.kind, crate::domain::MediaKind::Album);
assert!(album.is_folder);
assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
assert!(album.image_id.is_some(), "album card needs artwork");
// Track-only detail must not ride along on a container.
assert!(album.index_number.is_none());
assert!(album.album_id.is_none());
assert!(album.runtime_ticks.is_none());
// The movie keeps its place after the album its tracks stood in front of.
assert_eq!(collapsed[1].id, "mov-1");
}
/// When the server *did* group, its own album row wins — the tracks it also
/// returned must not add a second card for the same album.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
#[test]
fn test_collapse_prefers_the_album_row_the_server_returned() {
let album = item_from_json(
r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
"Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
);
let items = vec![
album,
track("trk-1", "So What", Some("alb-1")),
track("trk-2", "Blue in Green", Some("alb-1")),
];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(collapsed.len(), 1, "one album, one card");
assert_eq!(collapsed[0].id, "alb-1");
assert_eq!(
collapsed[0].overview.as_deref(),
Some("1959"),
"the server's own album row must survive, not a track-built stand-in"
);
}
/// A track with no album has no container to collapse into, so it stays —
/// same reasoning that leaves movies alone.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
#[test]
fn test_collapse_leaves_a_standalone_track_alone() {
let items = vec![track("trk-1", "Field Recording", None)];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(collapsed.len(), 1);
assert_eq!(collapsed[0].id, "trk-1");
assert_eq!(collapsed[0].item_type, "Audio");
}
/// Collapsing shrinks the listing, so the request has to over-fetch: asking
/// for exactly 16 rows and then folding one 14-track album into them leaves
/// an almost empty "Recently Added".
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
#[test]
fn test_latest_items_over_fetches_before_collapsing() {
assert!(
latest_items_fetch_limit(16) > 16,
"must ask for more rows than the row shows"
);
let endpoint =
build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
}
/// UT-190 — Next Up asks the server to leave resumable episodes out.
///
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
+8 -80
View File
@@ -9,7 +9,7 @@
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
//! can be unit-tested without standing up a repository.
//!
//! TRACES: UR-062 | DR-101, DR-264 | UT-239
//! TRACES: UR-062 | DR-101
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
@@ -83,34 +83,6 @@ fn is_played(item: &MediaItem) -> bool {
.unwrap_or(false)
}
/// Has the viewer reached the end of this episode?
///
/// The played *flag* is not enough. Nothing records completion locally — the
/// stop report writes a position, and the cache mirror carries the server's
/// flag only on the next refresh — so within seconds of an episode ending the
/// only local evidence that it is over is its position, parked at the very end
/// of its runtime. Leaving the player with Back reloads the series page inside
/// that window (DR-264).
fn is_finished(item: &MediaItem) -> bool {
if is_played(item) {
return true;
}
let Some(user_data) = item.user_data.as_ref() else {
return false;
};
let position_ms = user_data
.playback_position_ms
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
.unwrap_or(0);
// Without a duration a position says nothing about how much is left.
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
return false;
};
position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
}
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
item.series_id.as_deref() == Some(series_id)
}
@@ -158,20 +130,11 @@ pub fn pick_current_episode(
return Some(found.clone());
}
// 2. Next Up for this series — unless it names an episode we can already
// see is over. Next Up is the server's answer, and the server is one
// stop-report behind for a moment after an episode ends; the local
// position is not, so a finished candidate is dropped rather than handed
// back as "up next" (DR-264).
if let Some(found) = next_up.iter().find(|e| {
if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
return false;
}
// Judge it by the copy from `episodes` when there is one: that is the
// copy carrying the local user-data.
let local = episodes.iter().find(|listed| listed.id == e.id);
!is_finished(local.unwrap_or(e))
}) {
// 2. Next Up for this series.
if let Some(found) = next_up
.iter()
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
{
// Prefer the copy from `episodes` when we have one: it carries the
// user-data and images the list already fetched.
let matched = episodes.iter().find(|e| e.id == found.id);
@@ -182,7 +145,7 @@ pub fn pick_current_episode(
// unwatched: a viewer who skipped the pilot but is deep into season 3
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
// where they stopped is the *last* thing they watched.
if let Some(furthest) = episodes.iter().rposition(is_finished) {
if let Some(furthest) = episodes.iter().rposition(is_played) {
if let Some(found) = episodes.get(furthest + 1) {
return Some(found.clone());
}
@@ -190,7 +153,7 @@ pub fn pick_current_episode(
// Nothing watched yet (or the furthest-watched episode is the finale):
// the first unwatched episode in series order.
if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
return Some(found.clone());
}
@@ -464,41 +427,6 @@ mod tests {
assert_eq!(current.id, "s2e1");
}
/// The episode the viewer just finished must not still be "up next".
///
/// Leaving the player with Back reloads the series page within a second of
/// the stop report, and Jellyfin's Next Up can still name the episode that
/// just ended. Locally we know better: the position sits at the very end of
/// its runtime.
///
/// TRACES: UR-062 | DR-264 | UT-239
#[test]
fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
// Just finished: the position is at the end, the flag has not landed.
eps[1] = in_progress(eps[1].clone(), 0.99);
// The server has not caught up with the stop report.
let next_up = vec![episode("s1e2", 1, 2)];
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
assert_eq!(current.id, "s1e3");
}
/// The same, offline: with no Next Up to lean on, an episode watched to the
/// end counts as watched when scanning for the furthest-watched one.
///
/// TRACES: UR-062 | DR-264 | UT-239
#[test]
fn an_episode_watched_to_the_end_counts_as_watched_offline() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
eps[1] = in_progress(eps[1].clone(), 0.99);
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e3");
}
#[test]
fn a_never_watched_series_opens_on_its_premiere() {
let eps = [season(2, 3), season(1, 3)].concat();
@@ -184,45 +184,6 @@ impl StreamSelection {
needs_transcoding: false,
}
}
/// A selection for an item already sitting in the queue.
///
/// The queue predates `StreamSelection`: its items carry a URL, an optional
/// transport and the older `needs_transcoding` flag. This rebuilds a
/// selection from those without re-negotiating with the server, so the
/// controller can hand an engine an `OpenRequest` for an item it already
/// holds.
///
/// The transport falls back rather than being sniffed from the URL — the
/// substring check is exactly what DR-230 removed. `needs_transcoding` is an
/// exact stand-in because every transcode this app requests is HLS (DR-140).
///
/// TRACES: UR-079, UR-081 | DR-225, DR-245
pub fn for_queued_item(
url: impl Into<String>,
transport: Option<Transport>,
needs_transcoding: bool,
) -> Self {
let transport = transport.unwrap_or(if needs_transcoding {
Transport::Hls
} else {
Transport::Progressive
});
Self {
url: url.into(),
transport,
playback_kind: if needs_transcoding {
PlaybackKind::Transcode
} else {
PlaybackKind::DirectPlay
},
rendition: None,
available: Vec::new(),
media_source_id: None,
play_session_id: None,
needs_transcoding,
}
}
}
/// Build the quality ladder as it applies to a source of a known bitrate.
-30
View File
@@ -342,36 +342,6 @@ pub struct GetItemsOptions {
/// TRACES: UR-067 | DR-116 | UT-104
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
/// What the container being listed *is*, so the repository can pick the
/// order its children belong in when the caller names none. The frontend
/// sends the neutral kind it already holds; what that kind implies about
/// ordering is decided here, the same division as `SearchScope`.
///
/// TRACES: UR-007 | DR-257
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_kind: Option<crate::domain::MediaKind>,
}
/// The order a container's children take when the caller asked for none.
///
/// Ordering by *name* is right for a library, a series or an album, and wrong
/// for a channel folder: plugin channels — a podcast feed, say — carry a
/// release date and are read newest-first, and Jellypod additionally prefixes
/// played episodes with "[Played]", so a name sort clumped every heard episode
/// at the top of the list. Returns `None` when no container kind was given, so
/// callers that deliberately rely on the server's own order keep it.
///
/// This mapping is domain vocabulary and lives here rather than in the
/// frontend, for the reason in docs/specs/scoped-search-boundary.md.
///
/// TRACES: UR-007 | DR-257 | UT-229
pub fn default_listing_sort(
parent_kind: Option<crate::domain::MediaKind>,
) -> Option<(&'static str, &'static str)> {
match parent_kind? {
crate::domain::MediaKind::ChannelFolder => Some(("PremiereDate", "Descending")),
_ => Some(("SortName", "Ascending")),
}
}
/// An opaque search scope the frontend selects; Rust owns what it *means*.
-248
View File
@@ -28,7 +28,6 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("021_rebuild_items_fts", MIGRATION_021),
("022_people_fts", MIGRATION_022),
("023_downloads_expiry", MIGRATION_023),
("024_multi_user_profiles", MIGRATION_024),
];
/// Initial schema migration
@@ -820,250 +819,3 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
ON downloads(download_source, expires_at);
"#;
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
///
/// Three tables, one purpose each:
///
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
/// wrapping the access token, because a wrapped token would leave a locked
/// profile unable to resume its own downloads or drain its own sync queue
/// until someone typed the code. See DR-268 for why that trade was taken.
/// - `user_item_visibility` records what the server has actually shown to each
/// user. It is written as a byproduct of the cache write path, never rebuilt,
/// so it cannot disagree with what the server returned.
/// - `download_grants` separates the bytes from the claim on them, so one file
/// can serve several profiles and is unlinked only when the last claim goes.
///
/// The backfill is not optional. Every existing cache row and download predates
/// the concept of a user; without it an upgrading install's library goes blank.
/// It grants the *active* user only — other pre-existing rows re-populate from
/// the server on next browse, which is strictly safer than handing every
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
/// install whose single user somehow has `is_active = 0`.
///
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
const MIGRATION_024: &str = r#"
CREATE TABLE IF NOT EXISTS user_pins (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pin_hash TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_item_visibility (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, item_id)
);
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
CREATE TABLE IF NOT EXISTS user_libraries (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, library_id)
);
CREATE TABLE IF NOT EXISTS download_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, download_id)
);
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
-- Backfill: the active user has seen everything already cached on this device.
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
SELECT u.id, i.id
FROM users u CROSS JOIN items i
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
SELECT u.id, l.id
FROM users u CROSS JOIN libraries l
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
-- Downloads already record who asked for them, so every existing row becomes
-- exactly one grant held by its original requester.
INSERT OR IGNORE INTO download_grants (user_id, download_id)
SELECT d.user_id, d.id FROM downloads d;
"#;
#[cfg(test)]
mod migration_024_tests {
use super::*;
use rusqlite::{params, Connection};
/// Build a database at the schema version *before* multi-user profiles, so
/// the backfill is exercised against rows that predate it — which is the
/// only state that matters, and the one an in-memory database created from
/// the full migration list can never reproduce.
fn pre_024_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let upto = MIGRATIONS
.iter()
.position(|(name, _)| *name == "024_multi_user_profiles")
.expect("migration 024 must be registered");
for (_, sql) in &MIGRATIONS[..upto] {
conn.execute_batch(sql).unwrap();
}
conn
}
fn seed(conn: &Connection, active_user: &str) {
conn.execute(
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
params![active_user],
)
.unwrap();
conn.execute(
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
params![active_user],
)
.unwrap();
}
fn apply_024(conn: &Connection) {
let (_, sql) = MIGRATIONS
.iter()
.find(|(name, _)| *name == "024_multi_user_profiles")
.unwrap();
conn.execute_batch(sql).unwrap();
}
fn count(conn: &Connection, sql: &str) -> i64 {
conn.query_row(sql, [], |r| r.get(0)).unwrap()
}
/// UT: upgrading an existing install does not blank its library. Without the
/// backfill every cached item becomes invisible to the only user there is.
#[test]
fn backfill_keeps_the_existing_library_visible() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
),
1,
"the active user must still see what was already cached"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
),
1
);
}
/// UT: an existing download becomes exactly one grant, held by whoever asked
/// for it — the file is not orphaned and is not handed to anyone else.
#[test]
fn backfill_grants_downloads_to_their_requester() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
),
1
);
}
/// UT: a second profile added later starts with an empty view. The cache was
/// filled by someone else's browsing and the server never showed it to them,
/// so inheriting it is the leak this whole table exists to close.
#[test]
fn a_later_profile_inherits_nothing() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
[],
)
.unwrap();
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
),
0,
"a profile added after the upgrade must not inherit another's cache"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
),
0
);
}
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
/// library back — the fallback arm of the backfill.
#[test]
fn backfill_covers_an_install_with_no_active_flag() {
let conn = pre_024_db();
seed(&conn, "dad");
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
),
1
);
}
/// UT: removing a profile takes its per-user rows with it, so a re-added
/// account starts clean rather than resuming someone's stale view.
#[test]
fn removing_a_profile_cascades_its_rows() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
.unwrap();
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.5",
"version": "0.10.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -17,8 +17,7 @@
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"transparent": true
"resizable": true
}
],
"security": {
+10 -222
View File
@@ -145,37 +145,18 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
},
/**
* Switch audio track.
* Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
* Note: Frontend should handle saving series preferences after this command succeeds
*
* What decides the route is **whether the stream in front of the engine
* carries the requested track at all** see
* [`determine_audio_track_switch_strategy`]:
* The split is the requirement: an HTML5 `<video>` element cannot be told to
* change audio track, so the stream is re-opened at the chosen
* `AudioStreamIndex` and the frontend seeks the reloaded element back to
* `position`; a native backend (ExoPlayer) switches in place by track-group
* index. libmpv implements neither it is the audio-only backend here and
* leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
* which is why IR-019 is met by these two paths rather than by MPV.
*
* - An HTML5 `<video>` element has no track-selection API, so the stream is
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
* the reloaded element back to `position`.
* - A native backend playing a **direct play** holds the source file with
* every track in it, so ExoPlayer selects in place by track-group index.
* - A native backend playing a **transcode** does not. Jellyfin builds a
* transcode around one `AudioStreamIndex`, so the alternate tracks are not
* in the stream; the switch has to re-open it, which this command does
* itself and resumes at `current_position`.
*
* That last case is a bug fix, and it was the common case on Android: any
* source whose default audio codec the device cannot decode is transcoded, so
* ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
* file. The old code called `setAudioTrack(n)` regardless, which indexes
* ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
* audio track index` and dropped the request — the default track just kept
* playing, with nothing in the UI saying so.
*
* libmpv implements neither selection nor reload here it is the audio-only
* backend and leaves `PlayerBackend::set_audio_track` at its
* `not_implemented()` default, which is why IR-019 is met by these paths
* rather than by MPV.
*
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
* TRACES: UR-021 | IR-019, DR-024
*/
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
@@ -1826,107 +1807,6 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
},
/**
* Add another account from the **current** server to this device.
*
* Takes no server URL. That is the same-server constraint expressed as a
* signature rather than as form validation: there is no way to ask this command
* for an account somewhere else.
*
* TRACES: UR-082 | DR-267
*/
async profilesAdd(username: string, password: string, pinCode: string | null, deviceId: string) : Promise<Profile> {
return await TAURI_INVOKE("profiles_add", { username, password, pinCode, deviceId });
},
/**
* Read the "ask who's watching on start" setting.
*
* Separate from [`profiles_startup_target`] on purpose: the target can be
* `Picker` for reasons that have nothing to do with this setting a
* PIN-protected last profile always asks so deriving the toggle's position
* from it would show the user a switch that does not describe what it controls.
*
* TRACES: UR-082 | DR-274
*/
async profilesGetAskOnStart() : Promise<boolean> {
return await TAURI_INVOKE("profiles_get_ask_on_start");
},
/**
* List the accounts this device knows for the current server.
*
* TRACES: UR-082 | DR-267
*/
async profilesList() : Promise<Profile[]> {
return await TAURI_INVOKE("profiles_list");
},
/**
* Forget a profile on this device.
*
* Does not call Jellyfin's logout endpoint: removing an account from the family
* TV should not sign that person out on their phone. The stored token is
* deleted locally, which is the part that actually belongs to this device.
*
* TRACES: UR-082 | DR-267
*/
async profilesRemove(userId: string) : Promise<null> {
return await TAURI_INVOKE("profiles_remove", { userId });
},
/**
* Turn "ask who's watching on start" on or off.
*
* TRACES: UR-082 | DR-274
*/
async profilesSetAskOnStart(enabled: boolean) : Promise<null> {
return await TAURI_INVOKE("profiles_set_ask_on_start", { enabled });
},
/**
* Set, change, or clear a profile's PIN.
*
* Changing an existing PIN requires the current one. Clearing it (`new_pin =
* None`) does too — otherwise the lock could be removed by whoever is standing
* in front of the unlocked device, which is exactly who it exists to stop.
*
* TRACES: UR-083 | DR-268
*/
async profilesSetPin(userId: string, currentPin: string | null, newPin: string | null) : Promise<null> {
return await TAURI_INVOKE("profiles_set_pin", { userId, currentPin, newPin });
},
/**
* Whether startup should resume an account or ask who is watching.
*
* The decision is backend state, so the frontend asks rather than computes it.
*
* TRACES: UR-082 | DR-274
*/
async profilesStartupTarget() : Promise<StartupTarget> {
return await TAURI_INVOKE("profiles_startup_target");
},
/**
* Enter a profile, with its PIN if it has one.
*
* A profile with no PIN ignores whatever `pin` was passed the frontend cannot
* invent a lock the backend does not have, and cannot skip one it does.
*
* TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
*/
async profilesUnlock(userId: string, pinCode: string | null) : Promise<UnlockOutcome> {
return await TAURI_INVOKE("profiles_unlock", { userId, pinCode });
},
/**
* Enter a profile with its Jellyfin password, for someone who has forgotten
* their PIN.
*
* There is deliberately no reset token and no recovery secret: the account's
* own password is already the authority over it, and a second credential
* guarding the same thing would only be a weaker one. A successful password
* entry also clears the lockout, which is what makes a forgotten PIN a
* detour rather than a dead end.
*
* TRACES: UR-084 | DR-269
*/
async profilesUnlockWithPassword(userId: string, password: string, deviceId: string) : Promise<UnlockOutcome> {
return await TAURI_INVOKE("profiles_unlock_with_password", { userId, password, deviceId });
},
/**
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
*/
@@ -2425,16 +2305,7 @@ export type GetItemsOptions = { startIndex?: number | null; limit?: number | nul
*
* TRACES: UR-067 | DR-116 | UT-104
*/
favoritesOnly?: boolean | null;
/**
* What the container being listed *is*, so the repository can pick the
* order its children belong in when the caller names none. The frontend
* sends the neutral kind it already holds; what that kind implies about
* ordering is decided here, the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257
*/
parentKind?: MediaKind | null }
favoritesOnly?: boolean | null }
/**
* Image options
*/
@@ -2592,16 +2463,6 @@ export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "s
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A *container* inside a channel a Jellyfin `ChannelFolderItem` that is
* itself a folder, e.g. one podcast within a podcast channel. Distinct
* from `Folder` because its children are plugin content with an order of
* their own (newest episode first), which a folder's name order silently
* overrode.
*
* TRACES: UR-007 | DR-257
*/
"channelFolder" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
@@ -3299,16 +3160,6 @@ alreadyDownloaded: number;
* Number of tracks skipped (no jellyfin ID or other reasons)
*/
skipped: number }
/**
* A switchable account on this device.
*
* TRACES: UR-082 | DR-267
*/
export type Profile = { userId: string; username: string; serverId: string;
/**
* Jellyfin's primary-image tag, for the tile. `None` renders initials.
*/
avatarTag: string | null; unlockMethod: UnlockMethod; lastUsedAt: string | null; isActive: boolean }
/**
* One rung of the quality picker, as it applies to *this* media source.
*
@@ -3469,25 +3320,6 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
* SmartCache statistics
*/
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
/**
* What the app should do when it starts.
*
* The decision is backend state (profile count, PIN presence, a stored
* setting), so the frontend asks rather than computes. A single account with no
* PIN always resumes, which is what keeps this feature invisible until it is
* wanted.
*
* TRACES: UR-082 | DR-274
*/
export type StartupTarget =
/**
* Resume this profile without asking.
*/
{ type: "resume"; userId: string } |
/**
* Show the picker.
*/
{ type: "picker" }
/**
* Storage statistics for downloads
*/
@@ -3688,50 +3520,6 @@ export type Transport =
* server standing in front of one.
*/
{ type: "localFile" }
/**
* How a profile is entered.
*
* Deliberately not "adult"/"child": the app has no way to know a person's age
* and no business encoding one. It knows whether a code is set.
*
* TRACES: UR-083 | DR-276
*/
export type UnlockMethod =
/**
* One tap. No code set.
*/
"none" |
/**
* A numeric code gates the switch.
*/
"pin"
/**
* The result of an unlock attempt.
*
* Note the explicit field renames. tauri-specta emits tagged-union *fields*
* with their Rust names rather than camelCasing them, so a field that would
* differ between the two conventions is renamed here by hand the same trap
* that produced `new_url` on the frontend once already.
*
* TRACES: UR-083, UR-084 | DR-268, DR-269
*/
export type UnlockOutcome =
/**
* Switched. The profile is now active.
*/
{ type: "ok"; userId: string } |
/**
* Wrong code, attempts left.
*/
{ type: "wrongPin"; attemptsRemaining: number } |
/**
* Too many wrong codes; refused until this RFC3339 instant.
*/
{ type: "lockedOut"; until: string } |
/**
* No code is recoverable from here sign in with the account password.
*/
{ type: "needsPassword" }
/**
* User information
*/
-118
View File
@@ -1,118 +0,0 @@
<!--
Numeric PIN entry.
Presentation only: it collects digits and hands them up. It does not know the
PIN, does not compare anything, and does not count attempts — the backend
returns an `UnlockOutcome` and this renders whatever it says. A pad that could
decide would be a lock the webview could pick.
Sized for a living room: large targets, usable with a remote's arrow keys as
well as a touchscreen.
TRACES: UR-083 | DR-276
-->
<script lang="ts">
interface Props {
/** Digits entered so far. */
value: string;
/** Message under the dots — wrong PIN, lockout, etc. */
error?: string | null;
/** Blocks input while an attempt is in flight or the profile is locked out. */
disabled?: boolean;
maxLength?: number;
onsubmit: (pin: string) => void;
oncancel: () => void;
}
let {
value = $bindable(),
error = null,
disabled = false,
maxLength = 8,
onsubmit,
oncancel,
}: Props = $props();
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"];
function press(key: string) {
if (disabled) return;
if (key === "⌫") {
value = value.slice(0, -1);
} else if (key && value.length < maxLength) {
value = value + key;
}
}
function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
if (/^[0-9]$/.test(event.key)) {
event.preventDefault();
press(event.key);
} else if (event.key === "Backspace") {
event.preventDefault();
press("⌫");
} else if (event.key === "Enter" && value.length >= 4) {
event.preventDefault();
onsubmit(value);
} else if (event.key === "Escape") {
event.preventDefault();
oncancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="flex flex-col items-center gap-6">
<!-- Entered digits, shown as dots. -->
<div class="flex gap-3 h-4 items-center" aria-live="polite" aria-label="PIN entry">
{#each Array(Math.max(value.length, 4)) as _, i (i)}
<div
class="rounded-full transition-all {i < value.length
? 'w-3 h-3 bg-white'
: 'w-3 h-3 bg-gray-600'}"
></div>
{/each}
</div>
{#if error}
<p class="text-red-400 text-sm text-center max-w-xs" role="alert">{error}</p>
{/if}
<div class="grid grid-cols-3 gap-3">
{#each KEYS as key (key)}
{#if key === ""}
<div></div>
{:else}
<button
type="button"
onclick={() => press(key)}
{disabled}
class="w-18 h-18 min-w-[4.5rem] min-h-[4.5rem] rounded-full bg-[var(--color-surface)] hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--color-jellyfin)] disabled:opacity-40 disabled:cursor-not-allowed text-2xl font-light transition-colors"
aria-label={key === "⌫" ? "Delete" : key}
>
{key}
</button>
{/if}
{/each}
</div>
<div class="flex gap-3 w-full max-w-xs">
<button
type="button"
onclick={oncancel}
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)] transition-colors"
>
Cancel
</button>
<button
type="button"
onclick={() => onsubmit(value)}
disabled={disabled || value.length < 4}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
Unlock
</button>
</div>
</div>
+1 -27
View File
@@ -4,7 +4,7 @@
Settings, Display) and Sign out. Available on every authenticated,
non-immersive screen via the shared AppHeader.
TRACES: UR-054, UR-082 | DR-075, DR-276
TRACES: UR-054 | DR-075
-->
<script lang="ts">
import { tick } from "svelte";
@@ -105,32 +105,6 @@
<div class="border-t border-gray-700 my-1"></div>
<!--
Grouped with the identity block above rather than with the destinations
below: this changes *who* the app is, not where it goes. Shown even on a
device with one account, because the picker is also where a second one is
added — hiding it until a second profile exists would leave no way in
from here. (DR-276)
-->
<a
href="/profiles"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
Switch profile
</a>
<div class="border-t border-gray-700 my-1"></div>
<a
href="/downloads"
role="menuitem"
+1 -16
View File
@@ -73,22 +73,7 @@ describe("AccountMenu", () => {
render(AccountMenu);
openMenu();
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
expect(items).toEqual(["Switch profile", "Downloads", "Settings", "Display", "Sign out"]);
});
/**
* "Switch profile" sits directly under the identity block, before the
* destinations. It answers "who is this?", not "where do I go?", and burying
* it in Settings is what this entry exists to undo.
*
* TRACES: UR-082 | DR-276
*/
it("puts Switch profile first, next to the identity block", async () => {
render(AccountMenu);
openMenu();
const items = screen.getAllByRole("menuitem");
expect(items[0].textContent?.trim()).toBe("Switch profile");
expect(items[0].getAttribute("href")).toBe("/profiles");
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
});
it("shows the identity block with name and server host", async () => {
@@ -1,4 +1,4 @@
<!-- TRACES: UR-007 | DR-007, DR-262 -->
<!-- TRACES: UR-007 | DR-007 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
@@ -6,14 +6,8 @@
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*
* The strip's floor is the scroll container's own bottom edge, measured — not
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
* that distinction is the whole bug (DR-262).
*/
import { stripHeightFor } from "./alphabetStrip";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
@@ -22,45 +16,43 @@
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump }: Props = $props();
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
/**
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
* by tag name: the library routes scroll in a `<main>`, but the root shell
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
* to the viewport — which is exactly the too-tall strip this replaced.
*/
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
node = node.parentElement;
}
return null;
}
function measure() {
if (!container) return;
const scroller = nearestScroller(container);
stripHeight = stripHeightFor({
stripTop: container.getBoundingClientRect().top,
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
viewportHeight: window.innerHeight,
});
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
measure();
const scroller = nearestScroller(container);
// Observing the scroller is what makes the mini player showing or hiding
// re-measure: it is an in-flow sibling, so the scroller resizes when it
// appears. No store subscription and no platform guess needed.
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
@@ -72,6 +64,15 @@
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
@@ -8,7 +8,6 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -89,6 +88,18 @@
: null,
);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.durationMs) {
return 0;
@@ -106,7 +117,7 @@
}
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs, "h m"));
const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode));
</script>
@@ -6,6 +6,8 @@
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -257,6 +259,11 @@
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
</script>
<div class="space-y-6">
@@ -356,7 +363,7 @@
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
</div>
{/if}
</div>
@@ -1,88 +0,0 @@
import { describe, it, expect } from "vitest";
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
/**
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
* alphabet could not be tapped.
*
* The strip used to size itself against `window.innerHeight` minus a hardcoded
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
* whether the mini player was showing). Those bars stopped being fixed overlays
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
* has no relationship to the real stack and it is short on any device with a
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
*
* The invariant every case below asserts: the strip must end at or above the
* scroller's own bottom edge, which is exactly the top of the mini player.
*
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
*/
/** 800px-tall phone viewport; the library scroller starts 120px down. */
const VIEWPORT = 800;
const STRIP_TOP = 120;
/** Measured heights of the real bottom UI, in CSS px. */
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
function bounds(bottomUiHeight: number): StripHeightInput {
return {
stripTop: STRIP_TOP,
scrollerBottom: VIEWPORT - bottomUiHeight,
viewportHeight: VIEWPORT,
};
}
describe("stripHeightFor", () => {
it("keeps the last letter above the bottom nav when nothing is playing", () => {
const input = bounds(NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("keeps the last letter above the mini player while audio plays", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("survives the taller mini player of remote mode", () => {
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("still fills the space it does have, rather than stopping short", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const available = input.scrollerBottom! - STRIP_TOP;
// Within one letter's worth of the space available (letters are ~16px).
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
});
it("falls back to the viewport when the strip has no scroll container", () => {
const height = stripHeightFor({
stripTop: STRIP_TOP,
scrollerBottom: null,
viewportHeight: VIEWPORT,
});
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
});
it("never returns a negative height when the strip is scrolled past the floor", () => {
const height = stripHeightFor({
stripTop: 900,
scrollerBottom: 600,
viewportHeight: VIEWPORT,
});
expect(height).toBe(0);
});
});
@@ -1,54 +0,0 @@
/**
* Pure geometry for the A-Z jump strip (see AlphabetScrollBar.svelte).
*
* The strip stretches from wherever it sits down to a floor, and the floor is
* the whole question: get it wrong and the tail of the alphabet renders past
* the bottom of the scroller, under the mini player / bottom nav, where it
* cannot be tapped.
*
* The floor is the scroller's own bottom edge — never the viewport's. The strip
* used to size itself as `window.innerHeight` minus a hardcoded guess at the
* bars' height (5rem/7rem/11rem by platform and mini-player visibility), which
* dates from when those bars were `position: fixed` overlays. They are in-flow
* flex siblings below the scroller now (see BottomUi.svelte), so the scroller's
* bottom edge *is* the top of the mini player, measured exactly, every frame
* and the guess was short on every device with a navigation/gesture bar,
* because `--safe-bottom` is padded inside BottomUi and the guess never knew
* about it.
*
* Extracted from the component so the floor rule is unit-testable the
* component only supplies measurements.
*
* TRACES: UR-007 | DR-007, DR-262
*/
/**
* Breathing room left under the last letter, in px. Mirrors the `top-2` sticky
* offset at the other end so the strip sits symmetrically in the scrollport.
*/
export const STRIP_BOTTOM_GAP = 8;
export interface StripHeightInput {
/** Viewport-relative top of the strip container (`getBoundingClientRect().top`). */
stripTop: number;
/**
* Viewport-relative bottom edge of the scroll container the strip lives in,
* or `null` when the strip has no scrollable ancestor to measure.
*/
scrollerBottom: number | null;
/** Viewport height — the fallback floor when there is no scroll container. */
viewportHeight: number;
/** Override for {@link STRIP_BOTTOM_GAP}, in px. */
gap?: number;
}
/** How tall the A-Z strip may be without running under the bottom bars. */
export function stripHeightFor({
stripTop,
scrollerBottom,
viewportHeight,
gap = STRIP_BOTTOM_GAP,
}: StripHeightInput): number {
const floor = scrollerBottom ?? viewportHeight;
return Math.max(0, floor - gap - stripTop);
}
+8 -1
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { playerController } from "$lib/player";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
@@ -35,6 +34,14 @@
let dragDisabled = $state(true);
const flipDurationMs = 200;
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleConsider(
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
) {
@@ -1,239 +0,0 @@
/**
* Regression tests for the video player's track / quality / subtitle menus,
* rendered against the REAL component.
*
* TRACES: UR-020, UR-021, UR-066, UR-074 | DR-256 | UT-227, UT-228
*
* Two defects shipped together, and neither is visible from a pure helper:
*
* 1. Each menu owned its own `show…` boolean and no toggle cleared the others,
* so opening the subtitle menu on top of the audio menu left two panels
* overlapping in the same corner the newer one covering rows of the
* older one, both still live.
*
* 2. Each panel was `absolute right-0` against *its own icon button*, which
* sits mid-row. A 200220 px panel hung off the left edge of a portrait
* phone, so half the tracks could not be read or tapped.
*
* Both are properties of the composition, so these tests drive the real
* markup: click the toggles, then assert what a viewer would see.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
function testSelection() {
return {
url: "http://x/master.m3u8",
transport: { type: "hls" },
playbackKind: { type: "transcode" },
rendition: null,
available: [
{
quality: "original",
label: "Original",
detail: "Source",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
{
quality: "high",
label: "8 Mbps",
detail: "1080p",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: true,
} as unknown as import("$lib/api/bindings").StreamSelection;
}
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: vi.fn(() => Promise.resolve()),
seekVideo: vi.fn(() => Promise.resolve()),
seek: vi.fn(() => Promise.resolve()),
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
switchAudioTrack: vi.fn(() => Promise.resolve()),
setStreamQuality: vi.fn(() => Promise.resolve(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(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
/** Two audio tracks and one subtitle track — enough for all three menus. */
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000,
durationMs: 600_000,
mediaStreams: [
{ index: 1, kind: "audio", displayTitle: "English AAC", language: "eng", isDefault: true },
{ index: 2, kind: "audio", displayTitle: "Commentary", language: "eng" },
{
index: 3,
kind: "subtitle",
displayTitle: "English SRT",
language: "eng",
codec: "srt",
deliverableAsSidecar: true,
},
],
} as any;
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, selection: testSelection(), onClose: vi.fn() },
});
}
/** The menu panels currently on screen, found by their headings. */
function openPanels(container: HTMLElement): string[] {
return ["Audio Track", "Quality", "Subtitles"].filter((heading) =>
[...container.querySelectorAll("div")].some(
(el) => el.children.length === 0 && el.textContent?.trim() === heading,
),
);
}
function clickToggle(container: HTMLElement, label: string) {
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button, `expected a "${label}" button in the controls`).toBeTruthy();
return fireEvent.click(button!);
}
describe("VideoPlayer track menus", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
switch (cmd) {
case "player_get_streaming_qualities":
return [];
case "player_get_video_settings":
return { streamingQuality: "original" };
default:
return undefined;
}
});
});
// UT-227
it("opening one menu closes any other — never two panels stacked in the corner", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select audio track");
await tick();
expect(openPanels(container)).toEqual(["Audio Track"]);
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual(["Quality"]);
// A second click on the open menu's own toggle closes it.
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual([]);
});
// UT-227
it("the volume slider is part of the same group — it closes an open track menu", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
// The volume popup (desktop only) is a menu of this bar too.
const volume = container.querySelector<HTMLButtonElement>('button[title="Volume"]');
expect(volume).toBeTruthy();
await fireEvent.click(volume!);
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeTruthy();
expect(openPanels(container)).toEqual([]);
// …and a track menu closes the volume popup again.
await clickToggle(container, "Select subtitles");
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeNull();
expect(openPanels(container)).toEqual(["Subtitles"]);
});
// UT-228
it("the open panel is anchored to the control bar and clamped to the viewport", async () => {
const { container } = renderPlayer();
await tick();
for (const label of ["Select audio track", "Select subtitles", "Select streaming quality"]) {
await clickToggle(container, label);
await tick();
const panel = container.querySelector<HTMLElement>("[data-testid='player-menu']");
expect(panel, `${label} should open the shared menu panel`).toBeTruthy();
// Anchored to the control row, not to the icon button: a panel anchored
// to a mid-row button runs off the left edge in portrait.
const toggle = container.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
expect(panel!.contains(toggle!)).toBe(false);
expect(toggle!.parentElement!.contains(panel!)).toBe(false);
// …and never wider than the screen it opens on.
expect(panel!.className).toMatch(/max-w-\[|w-\[min\(/);
await clickToggle(container, label);
await tick();
}
});
});
+246 -328
View File
@@ -1,7 +1,6 @@
<!-- 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">
import { onMount, onDestroy, tick, untrack } from "svelte";
import { planFullscreen } from "./fullscreenTarget";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
@@ -80,13 +79,8 @@
shouldExitBackgroundAudio,
shouldResumeOnForeground,
planHandoffReturn,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
type BackgroundAudioState,
type BackgroundBehaviour,
} from "./backgroundAudioHandoff";
import { shouldApplyTimeUpdate } from "./timeTracking";
import { createLogger } from "$lib/utils/logger";
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
@@ -256,6 +250,7 @@
function nativeSeekSettling(): boolean {
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
}
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
let swipeType = $state<"brightness" | null>(null);
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
@@ -310,38 +305,18 @@
getMediaSourceId: () => mediaSourceId ?? null,
};
/**
* Which of the control bar's menus is open, if any.
*
* ONE piece of state for all three, deliberately. They each used to own a
* `show…` boolean and no toggle cleared the others, so opening the subtitle
* menu while the audio menu was up left two panels overlapping in the same
* corner — the second covering rows of the first, both still live and both
* still taking clicks. A single value makes "at most one menu is open" a
* property of the type rather than something every handler has to remember.
*
* TRACES: UR-020, UR-021, UR-074 | DR-256 | UT-227
*/
type PlayerMenu = "audio" | "quality" | "subtitle" | "volume";
let openMenu = $state<PlayerMenu | null>(null);
function toggleMenu(menu: PlayerMenu) {
openMenu = openMenu === menu ? null : menu;
}
function closeMenu() {
openMenu = null;
}
// Audio track selection
let showAudioTrackMenu = $state(false);
let selectedAudioTrackIndex = $state<number | null>(null);
// Subtitle track selection
let showSubtitleMenu = $state(false);
let selectedSubtitleIndex = $state<number | null>(null);
// Streaming bandwidth ceiling. The ladder and the current value both come from
// Rust — the frontend never encodes what a step means.
// TRACES: UR-074 | DR-162
let showQualityMenu = $state(false);
let changingQuality = $state(false);
/**
* The device's durable default, shown when the stream is a direct play and so
@@ -605,7 +580,7 @@
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: openMenu !== null,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
})
) {
return;
@@ -1056,6 +1031,7 @@
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
);
// Backend is kept running but should not play audio since HTML5 element handles playback
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the adapter with the facade so control intents (UI, or a
@@ -1121,6 +1097,7 @@
if (!useHtml5Element) {
// Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
isPlaying = (response.state?.kind ?? response.state) === "playing";
// Cleanup happens in the component's top-level onDestroy. Calling
// onDestroy() here — after an await — throws lifecycle_outside_component,
@@ -1161,6 +1138,7 @@
}
} else {
// For transcoded content, keep backend for seeking
didStartNativePlayback = true;
}
}
}
@@ -1294,25 +1272,14 @@
}
// Stop the player when component is destroyed
// Unconditional. Leaving the player means nothing should still be playing,
// whichever renderer happened to own it.
//
// This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
// — flags describing what *this component* started. A background-audio
// handoff swaps the renderer underneath them, so after one they describe a
// player that is no longer the one making sound, and the stop was skipped
// while the audio stream kept going. It then reappeared in the mini player
// as an audio track.
//
// `playerStop` is idempotent, so calling it when nothing is playing costs a
// no-op IPC round trip. That is a far cheaper failure than the alternative.
//
// TRACES: UR-040, UR-005 | DR-250
try {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("Failed to stop backend player:", err);
// Skip if we already stopped the backend early (non-transcoded + HTML5)
if (didStartNativePlayback && !didStopBackendEarly) {
try {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("Failed to stop backend player:", err);
}
}
// Report stop when component is destroyed (skip for live - no resume tracking)
@@ -1355,27 +1322,14 @@
}
}
// Second position source, alongside the RAF loop. It used to exclude itself
// whenever the video was playing, on the theory that RAF had it covered --
// but RAF only runs while the document is rendered, and an Android activity
// behind a PiP window is paused. `currentTime` then froze at the moment PiP
// was entered while the element played on, and every consumer of it froze
// too: the seek bar, the progress reports, the position mirrored into Rust,
// and -- the visible symptom -- the background-audio handoff, which resumed
// the audio-only stream back at the PiP-entry position. (DR-265)
// Fallback: Update time on timeupdate event (for when RAF isn't running)
function handleTimeUpdate() {
if (!videoElement) return;
if (
!shouldApplyTimeUpdate({
isPlaying,
isSeeking,
isDraggingSeekBar,
readyState: videoElement.readyState,
})
) {
return;
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
const newCurrentTime = seekOffset + videoElement.currentTime;
if (videoElement.readyState >= 2) {
currentTime = newCurrentTime;
}
}
currentTime = seekOffset + videoElement.currentTime;
}
function handleLoadedMetadata() {
@@ -1851,11 +1805,6 @@
const pipSupported = isPipSupported();
function handlePictureInPicture() {
// Pressing PiP is an unambiguous request to keep the picture, so it disarms
// the behaviour that throws the picture away. Exclusivity was previously
// enforced only from the toggle's side (it suppressed *auto*-PiP), leaving
// this button able to arm both at once. (DR-266)
applyBackgroundBehaviour(enteringPictureInPicture(backgroundBehaviour()));
enterPip();
}
@@ -1878,26 +1827,16 @@
// what we stopped -- never something the user paused themselves.
let pausedByBackgrounding = false;
/** The pair of background behaviours as they currently stand. */
function backgroundBehaviour(): BackgroundBehaviour {
return setBackgroundAudioArmed(backgroundAudioOn);
}
/**
* Push a background-behaviour pair to both natives, so exactly one is armed.
*/
function applyBackgroundBehaviour(next: BackgroundBehaviour) {
backgroundAudioOn = next.backgroundAudioArmed;
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
log.debug("Background-audio toggle ->", backgroundAudioOn);
const armed = setBackgroundAudioEnabled(next.backgroundAudioArmed);
if (!armed && next.backgroundAudioArmed) {
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// so exactly one background behavior is active.
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
if (!armed) {
log.warn("Background audio NOT armed natively (no bridge)");
}
setAutoEnterEnabled(next.autoPipEnabled);
}
function toggleBackgroundAudio() {
applyBackgroundBehaviour(setBackgroundAudioArmed(!backgroundAudioOn));
setAutoEnterEnabled(!backgroundAudioOn);
}
// App went to background/locked while background-audio is armed: hand off to
@@ -1912,15 +1851,7 @@
try {
action = await commands.playerBackgroundAction(
signal.backgroundAudioArmed,
// Not `signal.inPictureInPicture` alone. That is one sample of
// `isInPictureInPictureMode`, taken inside onStop(); there are
// orderings -- the keyguard dismissing the window, the window being
// stashed, OEM variance in when onPictureInPictureModeChanged(false)
// lands -- where it reads false with the window still on screen, and
// the video the user is watching is handed off to audio. `isInPip` is
// a latch over the pip-entered/exited events, which arrive on the same
// queue ahead of this one. (DR-266)
inPictureInPicture(signal.inPictureInPicture, isInPip),
signal.inPictureInPicture,
);
} catch (e) {
// Never leave playback in an undefined state because a decision call
@@ -2107,6 +2038,7 @@
transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetSelection.url, {
mediaId: media.id,
selection: targetSelection,
@@ -2153,47 +2085,23 @@
// Activity, so on its own it left the status and navigation bars painted over
// the video. The native bridge is what actually makes fullscreen full screen;
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
async function toggleFullscreen() {
// A native surface draws the picture *behind* the webview at window size, so
// fullscreening the document alone leaves the video at its old size while
// the page around it expands. See fullscreenTarget.ts. (DR-240)
const plan = planFullscreen(!useHtml5Element);
function toggleFullscreen() {
if (!document.fullscreenElement) {
if (plan.document) {
document.documentElement.requestFullscreen().catch((err) => {
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
// the immersive call below is what matters on Android, so don't let a
// rejection here abort it.
log.warn("requestFullscreen rejected:", err);
});
}
if (plan.osWindow) {
await setOsWindowFullscreen(true);
}
document.documentElement.requestFullscreen().catch((err) => {
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
// the immersive call below is what matters on Android, so don't let a
// rejection here abort it.
log.warn("requestFullscreen rejected:", err);
});
enterImmersive();
isFullscreen = true;
} else {
document.exitFullscreen();
if (plan.osWindow) {
await setOsWindowFullscreen(false);
}
exitImmersive();
isFullscreen = false;
}
}
/// Resize the OS window itself. Best-effort: a platform without a window to
/// resize (Android) must not break the rest of the toggle.
async function setOsWindowFullscreen(on: boolean) {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setFullscreen(on);
} catch (err) {
log.warn("setFullscreen on the OS window failed:", err);
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
@@ -2414,11 +2322,15 @@
}, 800);
}
function toggleAudioTrackMenu() {
showAudioTrackMenu = !showAudioTrackMenu;
}
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
const previousTrackIndex = selectedAudioTrackIndex;
selectedAudioTrackIndex = streamIndex;
closeMenu();
showAudioTrackMenu = false;
try {
// The BACKEND decides whether the audio-track switch needs a transcode
@@ -2471,6 +2383,10 @@
}
}
function toggleQualityMenu() {
showQualityMenu = !showQualityMenu;
}
/**
* Re-open the current stream at a different bandwidth ceiling.
*
@@ -2487,7 +2403,7 @@
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
*/
async function selectQuality(quality: StreamingQuality) {
closeMenu();
showQualityMenu = false;
if (quality === selectedQuality || changingQuality) return;
changingQuality = true;
@@ -2523,6 +2439,10 @@
}
}
function toggleSubtitleMenu() {
showSubtitleMenu = !showSubtitleMenu;
}
/**
* Show exactly one (or no) text track on the HTML5 element. `null` disables
* every track, which is what the menu's "Off" entry means.
@@ -2565,7 +2485,7 @@
async function selectSubtitle(streamIndex: number | null) {
log.debug("Selecting subtitle - streamIndex:", streamIndex);
selectedSubtitleIndex = streamIndex;
closeMenu();
showSubtitleMenu = false;
// For HTML5 video element, update the text tracks
if (useHtml5Element) {
@@ -2890,155 +2810,8 @@
</div>
{/if}
<!-- Control buttons.
`relative` because the track / quality / subtitle panel below is
anchored to this ROW, not to the icon that opens it. Anchoring each
panel to its own button put a 220 px panel under a mid-row icon, which
hangs off the left edge of a portrait phone. TRACES: UR-066 | DR-256 -->
<div class="relative flex items-center justify-between">
<!-- One panel, one open menu. Each menu used to own a `show…` boolean
that no other toggle cleared, so a second menu opened stacked on top
of the first. TRACES: DR-256 -->
{#if openMenu && openMenu !== "volume"}
<!-- Tapping anywhere else dismisses the menu. Inside the controls
subtree, so a tap here never reaches the container tap gestures
(DR-098) and it disappears with the bar. -->
<button
class="fixed inset-0 z-10 cursor-default"
onclick={closeMenu}
aria-label="Close menu"
tabindex="-1"
></button>
<div
data-testid="player-menu"
class="absolute bottom-full right-0 mb-2 z-20 w-[min(20rem,calc(100vw-2rem))] max-h-[min(300px,45vh)] overflow-y-auto bg-black/90 backdrop-blur-sm rounded-lg shadow-xl"
>
<div class="p-2">
{#if openMenu === "audio"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
</div>
{#each audioTracks() as track, i}
<button
onclick={() => selectAudioTrack(track.index, i)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<span class="text-sm">
{track.displayTitle || track.language || `Track ${i + 1}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
</span>
{#if selectedAudioTrackIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
{:else if openMenu === "quality"}
<div class="px-3 py-2 border-b border-white/20">
<div class="text-white text-sm font-semibold">Quality</div>
<!--
What the server is actually doing. Only knowable now that
the backend reports it. TRACES: UR-079 | DR-228
-->
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
</div>
{#each qualityOptions as option (option.quality)}
<button
onclick={() => selectQuality(option.quality)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
option.quality
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">{option.label}</span>
<span class="text-xs text-gray-400">
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
&middot; {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
</span>
</div>
{#if selectedQuality === option.quality}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
{:else if openMenu === "subtitle"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
</div>
<!-- Off option -->
<button
onclick={() => selectSubtitle(null)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
null
? 'bg-white/20'
: ''}"
>
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
<!-- Subtitle tracks -->
{#each subtitleTracks() as track}
<button
onclick={() => selectSubtitle(track.index)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">
{track.displayTitle || track.language || `Track ${track.index}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
{#if track.isForced}
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
{/if}
</span>
{#if track.codec}
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
{/if}
</div>
{#if selectedSubtitleIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button
@@ -3067,23 +2840,60 @@
{/if}
</div>
<!-- Wraps rather than overflowing: in portrait these icons plus the
transport controls are wider than the screen, and the last of them
(fullscreen, close) went off the edge. TRACES: UR-066 | DR-256 -->
<div class="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<div class="flex items-center gap-4">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<button
onclick={() => toggleMenu("audio")}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
<div class="relative">
<button
onclick={toggleAudioTrackMenu}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
<!-- Audio Track Menu -->
{#if showAudioTrackMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
</div>
{#each audioTracks() as track, i}
<button
onclick={() => selectAudioTrack(track.index, i)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<span class="text-sm">
{track.displayTitle || track.language || `Track ${i + 1}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
</span>
{#if selectedAudioTrackIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!--
@@ -3091,34 +2901,147 @@
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<button
onclick={() => toggleMenu("quality")}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
<div class="relative">
<button
onclick={toggleQualityMenu}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{#if showQualityMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="px-3 py-2 border-b border-white/20">
<div class="text-white text-sm font-semibold">Quality</div>
<!--
What the server is actually doing. Only knowable now that
the backend reports it. TRACES: UR-079 | DR-228
-->
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
</div>
{#each qualityOptions as option (option.quality)}
<button
onclick={() => selectQuality(option.quality)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
option.quality
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">{option.label}</span>
<span class="text-xs text-gray-400">
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
&middot; {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
</span>
</div>
{#if selectedQuality === option.quality}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<button
onclick={() => toggleMenu("subtitle")}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<div class="relative">
<button
onclick={toggleSubtitleMenu}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<!-- Subtitle Menu -->
{#if showSubtitleMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
</div>
<!-- Off option -->
<button
onclick={() => selectSubtitle(null)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
null
? 'bg-white/20'
: ''}"
>
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
<!-- Subtitle tracks -->
{#each subtitleTracks() as track}
<button
onclick={() => selectSubtitle(track.index)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">
{track.displayTitle || track.language || `Track ${track.index}`}
{#if track.isDefault}
<span class="text-xs text-gray-400 ml-1">(Default)</span>
{/if}
{#if track.isForced}
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
{/if}
</span>
{#if track.codec}
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
{/if}
</div>
{#if selectedSubtitleIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Sleep Timer -->
@@ -3144,13 +3067,8 @@
</button>
{/if}
<!-- Volume Control. Its popup is a menu of this bar like any other,
so the bar owns whether it is open. TRACES: DR-256 -->
<VolumeControl
size="md"
open={openMenu === "volume"}
onOpenChange={(next) => (openMenu = next ? "volume" : null)}
/>
<!-- Volume Control -->
<VolumeControl size="md" />
<!-- Picture-in-picture (Android only) -->
{#if pipSupported}
+8 -20
View File
@@ -7,31 +7,16 @@
interface Props {
size?: "sm" | "md" | "lg";
/**
* Controlled open state. Omit it and the slider manages its own; pass it
* (with `onOpenChange`) when the host has other menus that must not be
* open at the same time — the video player's control bar does.
*
* TRACES: DR-256
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { size = "md", open, onOpenChange }: Props = $props();
let { size = "md" }: Props = $props();
// On Android, volume is controlled by system volume buttons (not a slider)
const isAndroid = platform() === "android";
let selfOpen = $state(false);
const showSlider = $derived(open ?? selfOpen);
let showSlider = $state(false);
let sliderValue = $state($mergedVolume);
function setOpen(next: boolean) {
if (onOpenChange) onOpenChange(next);
else selfOpen = next;
}
// Sync slider with merged volume (handles both local and remote)
$effect(() => {
sliderValue = $mergedVolume;
@@ -59,7 +44,7 @@
}
function toggleSlider() {
setOpen(!showSlider);
showSlider = !showSlider;
}
// Icon sizes based on prop (use $derived for reactivity)
@@ -121,7 +106,7 @@
<!-- Volume Slider (toggle on click) -->
{#if showSlider}
<div
class="absolute bottom-full right-0 mb-2 max-w-[calc(100vw-2rem)] bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
role="group"
aria-label="Volume controls"
>
@@ -168,6 +153,9 @@
<!-- Click outside to close volume slider -->
{#if showSlider}
<button class="fixed inset-0 z-[65]" onclick={() => setOpen(false)} aria-label="Close volume"
<button
class="fixed inset-0 z-[65]"
onclick={() => (showSlider = false)}
aria-label="Close volume"
></button>
{/if}
@@ -7,9 +7,6 @@ import {
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
} from "./backgroundAudioHandoff";
// TRACES: UR-040 | DR-052 | UT-060
@@ -140,69 +137,3 @@ describe("backgroundAudioHandoff", () => {
});
});
});
/**
* TRACES: UT-246 | DR-266
*/
describe("background behaviour exclusivity", () => {
describe("setBackgroundAudioArmed", () => {
it("disables auto-PiP when background audio is armed", () => {
expect(setBackgroundAudioArmed(true)).toEqual({
backgroundAudioArmed: true,
autoPipEnabled: false,
});
});
it("restores auto-PiP when background audio is disarmed", () => {
expect(setBackgroundAudioArmed(false)).toEqual({
backgroundAudioArmed: false,
autoPipEnabled: true,
});
});
});
describe("enteringPictureInPicture", () => {
it("disarms background audio when the user opens a PiP window", () => {
// THE REPORTED BUG, half one. Exclusivity was enforced in one direction
// only: arming the toggle suppressed auto-PiP, but the PiP *button* was
// still offered and still worked, leaving both behaviours live at once.
// A single stray background signal then handed a video the user was
// watching in a PiP window off to audio-only.
expect(
enteringPictureInPicture({ backgroundAudioArmed: true, autoPipEnabled: false }),
).toEqual({ backgroundAudioArmed: false, autoPipEnabled: true });
});
it("leaves an already-exclusive state alone", () => {
const state = { backgroundAudioArmed: false, autoPipEnabled: true };
expect(enteringPictureInPicture(state)).toEqual(state);
});
});
describe("inPictureInPicture", () => {
it("trusts the native flag when the two agree", () => {
expect(inPictureInPicture(true, true)).toBe(true);
expect(inPictureInPicture(false, false)).toBe(false);
});
it("treats a live PiP window as PiP even when the native flag says otherwise", () => {
// THE REPORTED BUG, half two. `isInPictureInPictureMode` is sampled once,
// inside onStop(). There are orderings -- the keyguard dismissing the
// window, the window being stashed, OEM variance in whether
// onPictureInPictureModeChanged(false) lands first -- where the activity
// is stopped with a PiP window still on screen and that single boolean
// reads false. Backgrounding then means "the app is gone" and the video
// the user is watching is handed off to audio.
expect(inPictureInPicture(false, true)).toBe(true);
});
it("does not resurrect a window the frontend has already seen close", () => {
// jellytau-pip-exited and jellytau-background are both posted to the same
// WebView message queue, in that order, so a genuine exit is always known
// by the time the background signal is handled. Leaving playback running
// here would be the opposite defect: audio continuing after the user
// closed the window and left the app.
expect(inPictureInPicture(false, false)).toBe(false);
});
});
});
@@ -121,61 +121,3 @@ export function planHandoffReturn(opts: {
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
};
}
/**
* Which of the two mutually exclusive background behaviours is armed.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* Backgrounding the app can either shrink the video into a picture-in-picture
* window (UR-041) or hand its audio off to the native player and drop the
* picture (UR-040). They are alternatives the first keeps the video on
* screen, the second throws it away so at most one may ever be armed.
*/
export interface BackgroundBehaviour {
/** The per-player background-audio toggle (UR-040). */
backgroundAudioArmed: boolean;
/** Whether leaving the app auto-enters PiP (UR-041). */
autoPipEnabled: boolean;
}
/** Arming/disarming the background-audio toggle flips auto-PiP the other way. */
export function setBackgroundAudioArmed(armed: boolean): BackgroundBehaviour {
return { backgroundAudioArmed: armed, autoPipEnabled: !armed };
}
/**
* The user has asked for a PiP window, by pressing the button rather than by
* leaving the app.
*
* Exclusivity used to be enforced from one side only arming the toggle
* suppressed auto-PiP while the PiP button stayed live and ungated. Pressing
* it left both behaviours armed, and the video was then one stray background
* signal away from being handed off to audio-only while the user was watching
* it in the window. Pressing PiP is an unambiguous request to keep the picture,
* so it disarms the behaviour that throws the picture away.
*/
export function enteringPictureInPicture(_current: BackgroundBehaviour): BackgroundBehaviour {
return setBackgroundAudioArmed(false);
}
/**
* Whether the app is in a picture-in-picture window, for the purpose of
* deciding what backgrounding means.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* @param nativeFlag the Activity's `isInPictureInPictureMode`, sampled inside
* `onStop()`
* @param sawPipEntered whether the frontend has seen `jellytau-pip-entered`
* without a matching `jellytau-pip-exited`
*/
export function inPictureInPicture(nativeFlag: boolean, sawPipEntered: boolean): boolean {
// Either witness is enough. The native flag is a single sample taken inside
// onStop(); the frontend's is a latch, set by `jellytau-pip-entered` and
// cleared by `jellytau-pip-exited`. Both events reach the WebView through the
// same message queue in dispatch order, so a genuine exit is always known
// before the background signal that follows it — the latch can report a
// window that is still open, never one that has closed.
return nativeFlag || sawPipEntered;
}
@@ -1,15 +0,0 @@
import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
});
});
@@ -1,35 +0,0 @@
/**
* Which surfaces a fullscreen toggle has to move.
*
* `requestFullscreen()` only ever fullscreens the *document*. That was
* sufficient while every renderer lived inside it: the HTML5 `<video>` element
* is part of the document, so WebKit scaled it to the screen and the OS
* window's real size never mattered.
*
* A native video surface is drawn *behind* the webview at **window** size, so a
* document-only fullscreen leaves the picture exactly where it was while the
* page around it goes fullscreen. On WebKitGTK the observed result is a
* maximised window with decorations still taking a strip of the screen the
* video renders correctly, at the wrong size, which reads as "fullscreen is
* broken" rather than as a windowing problem.
*
* Android already needed its own answer here for the system bars (DR-157); this
* is the desktop equivalent of the same rule: whoever actually owns the pixels
* has to be the thing that goes fullscreen.
*
* TRACES: UR-066 | DR-240 | UT-219
*/
export interface FullscreenPlan {
/** Ask the document to go fullscreen (harmless everywhere, needed for CSS). */
document: boolean;
/** Resize the OS window itself. Required when a native surface owns the picture. */
osWindow: boolean;
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
}
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldApplyTimeUpdate } from "./timeTracking";
/**
* TRACES: UT-245 | DR-265
*/
describe("shouldApplyTimeUpdate", () => {
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
it("applies the update while the video is PLAYING", () => {
// THE REPORTED BUG. `timeupdate` was the only position source that still
// fires once requestAnimationFrame stops -- which is exactly what happens
// when the activity is paused behind a picture-in-picture window. Gating it
// on `!isPlaying` disabled it precisely when it was the only thing left,
// so the component's `currentTime` froze at the moment PiP was entered
// while the element played on. The background-audio handoff then resumed
// the audio-only stream at that frozen position.
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
});
it("still applies the update while paused", () => {
// The case it always handled: RAF is stopped, timeupdate carries the seek.
expect(shouldApplyTimeUpdate(base)).toBe(true);
});
it("yields to an in-flight seek", () => {
// A seek owns the position until it settles; a stale element read landing
// mid-seek is what makes a scrubbed video snap back.
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
});
it("yields while the user is dragging the seek bar", () => {
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
false,
);
});
it("ignores an element with no usable data yet", () => {
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Pure helpers for keeping the player's position variable honest.
*
* TRACES: UR-004, UR-041 | DR-265 | UT-245
*
* `VideoPlayer.svelte` tracks the absolute playback position in its own
* `currentTime` variable rather than reading `videoElement.currentTime` at the
* point of use transcoded HLS resets the element to 0 on every segment
* rebuild, so only the component's running total is meaningful. Everything
* downstream reads that variable: the seek bar, the progress reports, the
* position mirrored into Rust, and the background-audio handoff.
*
* Which makes "who is allowed to write it" a correctness question, not a
* rendering detail hence a pure module with tests rather than a condition
* buried in an event handler.
*/
export interface TimeUpdateGate {
/**
* Deliberately does NOT gate the update, and is accepted only to say so.
*
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
* document being rendered, and an Android activity behind a picture-in-picture
* window is paused: the loop stops while the element plays on, and the one
* remaining position source had switched itself off. Both writing the same
* derived value costs nothing the element is the authority either way.
*/
isPlaying?: boolean;
isSeeking: boolean;
isDraggingSeekBar: boolean;
readyState: number;
}
/**
* Whether a `timeupdate` event may write the component's position.
*
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
* player.
*/
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
// An in-flight seek or a drag owns the position until it settles, and an
// element with no current data reads 0, which would rewind it.
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
}
@@ -1,258 +0,0 @@
<!--
PIN management for the profile you are signed in as.
Deliberately scoped to the *active* profile. Letting a signed-in session set a
PIN on someone else's profile would be an escalation path with no real use —
a PIN-less profile could be locked by anyone standing at the device, and the
owner would be pushed down the password route to get back into their own
account. Other profiles are listed read-only so a parent can see at a glance
which are protected; adding and removing them lives on the picker.
Nothing here compares a PIN or counts an attempt. The form validates shape so
it can say what is wrong before submitting; Rust validates again and is the
only thing that ever verifies one.
TRACES: UR-082, UR-083 | DR-268, DR-276
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { profiles } from "$lib/stores/profiles";
import {
validatePinForm,
pinActionLabel,
PIN_MAX_LENGTH,
type PinIntent,
} from "$lib/utils/pinForm";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("ProfileSecurity");
let intent = $state<PinIntent | null>(null);
let currentPin = $state("");
let newPin = $state("");
let confirmPin = $state("");
let busy = $state(false);
let error = $state<string | null>(null);
let notice = $state<string | null>(null);
const active = $derived($profiles.profiles.find((p) => p.isActive) ?? null);
const others = $derived($profiles.profiles.filter((p) => !p.isActive));
const hasPin = $derived(active?.unlockMethod === "pin");
const validationError = $derived(
intent === null ? null : validatePinForm({ intent, hasPin, currentPin, newPin, confirmPin }),
);
onMount(() => {
void profiles.refresh();
});
function begin(next: PinIntent) {
intent = next;
currentPin = "";
newPin = "";
confirmPin = "";
error = null;
notice = null;
}
function cancel() {
intent = null;
currentPin = "";
newPin = "";
confirmPin = "";
error = null;
}
async function submit(event: Event) {
event.preventDefault();
if (!active || intent === null || validationError) return;
busy = true;
error = null;
try {
await profiles.setPin(
active.userId,
hasPin ? currentPin : null,
intent === "clear" ? null : newPin,
);
notice =
intent === "clear" ? "PIN turned off. This profile now opens with one tap." : "PIN saved.";
cancel();
} catch (e) {
log.error("Could not update PIN:", e);
error = e instanceof Error ? e.message : "Could not update the PIN";
} finally {
busy = false;
}
}
</script>
<div class="space-y-5">
{#if !active}
<p class="text-sm text-gray-400">No profile is signed in.</p>
{:else}
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white mb-1">
PIN for {active.username}
</h3>
<p class="text-sm text-gray-400">
{#if hasPin}
This profile asks for a PIN before anyone can switch to it.
{:else}
This profile opens with one tap — right for a child's account, and what you want unless
there is something to keep out.
{/if}
</p>
</div>
<span
class="shrink-0 text-xs px-2 py-1 rounded-full {hasPin
? 'bg-green-900/60 text-green-300'
: 'bg-gray-700 text-gray-300'}"
>
{hasPin ? "On" : "Off"}
</span>
</div>
{#if notice}
<p class="text-sm text-green-400">{notice}</p>
{/if}
{#if intent === null}
<div class="flex flex-wrap gap-3">
{#if hasPin}
<button
onclick={() => begin("change")}
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
>
Change PIN
</button>
<button
onclick={() => begin("clear")}
class="px-4 py-2 border border-gray-600 hover:bg-gray-700 rounded-lg transition-colors"
>
Turn off PIN
</button>
{:else}
<button
onclick={() => begin("set")}
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
>
Set a PIN
</button>
{/if}
</div>
{:else}
<form onsubmit={submit} class="space-y-4 max-w-sm">
{#if hasPin}
<div>
<label for="current-pin" class="block text-sm font-medium text-gray-300 mb-2">
Current PIN
</label>
<input
id="current-pin"
type="password"
inputmode="numeric"
maxlength={PIN_MAX_LENGTH}
bind:value={currentPin}
disabled={busy}
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
/>
</div>
{/if}
{#if intent !== "clear"}
<div>
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
New PIN (48 digits)
</label>
<input
id="new-pin"
type="password"
inputmode="numeric"
maxlength={PIN_MAX_LENGTH}
bind:value={newPin}
disabled={busy}
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
/>
</div>
<div>
<label for="confirm-pin" class="block text-sm font-medium text-gray-300 mb-2">
Confirm new PIN
</label>
<input
id="confirm-pin"
type="password"
inputmode="numeric"
maxlength={PIN_MAX_LENGTH}
bind:value={confirmPin}
disabled={busy}
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
/>
</div>
{:else}
<p class="text-sm text-gray-400">
Turning the PIN off means anyone using this device can switch to
{active.username} in one tap.
</p>
{/if}
{#if error}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{error}
</div>
{:else if validationError && (currentPin || newPin || confirmPin)}
<p class="text-sm text-amber-400">{validationError}</p>
{/if}
<div class="flex gap-3">
<button
type="button"
onclick={cancel}
disabled={busy}
class="flex-1 py-3 rounded-lg border border-gray-600 hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={busy || validationError !== null}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
{pinActionLabel(intent)}
</button>
</div>
</form>
{/if}
{#if others.length > 0}
<div class="border-t border-gray-700 pt-5">
<h3 class="text-sm font-semibold text-white mb-3">Other profiles on this device</h3>
<ul class="space-y-2">
{#each others as profile (profile.userId)}
<li class="flex items-center justify-between text-sm">
<span class="text-gray-300">{profile.username}</span>
<span class="text-xs text-gray-500">
{profile.unlockMethod === "pin" ? "PIN" : "No PIN"}
</span>
</li>
{/each}
</ul>
<p class="text-xs text-gray-500 mt-3">
A profile's PIN can only be changed from that profile. Sign in as them to set one.
</p>
</div>
{/if}
<div class="border-t border-gray-700 pt-5">
<button
onclick={() => goto("/profiles?manage=1")}
class="text-sm text-[var(--color-jellyfin)] hover:underline"
>
Add or remove profiles
</button>
</div>
{/if}
</div>
-61
View File
@@ -606,66 +606,6 @@ function createAuthStore() {
}
}
/**
* Rebuild this store's view of the world after the backend has switched
* profiles.
*
* The backend already flipped the active user, adopted the new session and
* destroyed the old repository handle in that order, which is the part that
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
* bound to the new session, and the player's reporting configuration.
*
* Deliberately *not* a login: no password is involved and no token is minted,
* because switching must leave both profiles able to come back with one tap.
*
* TRACES: UR-082 | DR-270
*/
async function adoptSwitchedSession() {
const session = await commands.authGetSession();
if (!session) throw new Error("No session after profile switch");
if (repository) {
try {
await repository.destroy();
} catch (error) {
log.error("Failed to destroy repository during switch:", error);
}
}
repository = new RepositoryClient();
await repository.create(
session.serverUrl,
session.userId,
session.accessToken,
session.serverId,
);
try {
const deviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
session.serverUrl,
session.accessToken,
session.userId,
deviceId,
);
} catch (error) {
log.error("Failed to reconfigure player after switch:", error);
}
set({
isAuthenticated: true,
isLoading: false,
user: { id: session.userId, name: session.username } as User,
serverUrl: session.serverUrl,
serverName: session.serverName,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: session.verified,
});
}
return {
subscribe,
initialize,
@@ -680,7 +620,6 @@ function createAuthStore() {
getUserId,
getServerUrl,
retryVerification,
adoptSwitchedSession,
cleanupEventListeners,
};
}
@@ -72,11 +72,8 @@ describe("waitForRepository", () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
// Nothing yet; the page has already mounted and asked. Published on a
// microtask rather than a timer: the point is *ordering* (asked before it
// arrived), and a wall-clock delay would make this a race under load.
await Promise.resolve();
w.publish(repo);
// Nothing yet; the page has already mounted and asked.
setTimeout(() => w.publish(repo), 10);
await expect(pending).resolves.toBe(repo);
});
+4 -15
View File
@@ -3,7 +3,7 @@
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, MediaKind, SearchResult, Genre } from "$lib/api/types";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
@@ -113,21 +113,9 @@ function createLibraryStore() {
}
}
// What a container's children are ordered by is domain knowledge, so the
// store names the *container* and Rust answers with the sort (see
// `default_listing_sort`). A channel folder — one podcast inside a plugin
// channel — is read newest-episode-first; naming `SortName` here, as this did
// for every drill-down, threw that order away.
//
// TRACES: UR-007 | DR-257 | UT-231
async function loadItems(
parentId: string,
options: {
startIndex?: number;
limit?: number;
genres?: string[];
parentKind?: MediaKind;
} = {},
options: { startIndex?: number; limit?: number; genres?: string[] } = {},
) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -141,7 +129,8 @@ function createLibraryStore() {
startIndex: options.startIndex ?? 0,
limit: options.limit ?? 10000,
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
parentKind: options.parentKind ?? "folder",
sortBy: "SortName",
sortOrder: "Ascending",
genres: options.genres,
});
@@ -1,53 +0,0 @@
/**
* What order a container's children come back in.
*
* The store used to pin `sortBy: "SortName"` onto every drill-down, which is
* where the podcast bug came from: a Jellypod channel folder lists its episodes
* newest-first, and an alphabetical sort not only lost that order but clumped
* every "[Played] …" title at the top. The store now says *what the container
* is* and lets Rust say how it orders the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257 | UT-231
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const getItemsMock = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("./auth", () => ({
auth: {
getRepository: () => ({ getItems: getItemsMock }),
},
}));
import { library } from "./library";
describe("library.loadItems ordering", () => {
beforeEach(() => {
getItemsMock.mockReset();
getItemsMock.mockResolvedValue({ items: [], totalRecordCount: 0 });
});
it("names the container rather than a sort field", async () => {
await library.loadItems("podcast-1", { parentKind: "channelFolder" });
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("channelFolder");
// Naming a sort field here would put the ordering rule back in the
// presentation layer, which is the leak this fix removes.
expect(options.sortBy).toBeUndefined();
expect(options.sortOrder).toBeUndefined();
});
it("falls back to a plain folder when the caller names no container", async () => {
await library.loadItems("library-1");
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("folder");
expect(options.sortBy).toBeUndefined();
});
});
-128
View File
@@ -1,128 +0,0 @@
/**
* Profile switching a thin wrapper over the Rust `profiles_*` commands.
*
* There is deliberately no logic here worth the name. This store does not
* compare a PIN, count an attempt, decide whether a profile is locked, or work
* out whether the picker should appear at startup. All of that is backend state
* and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a
* `StartupTarget`. A store that re-derived any of it would be a gate the webview
* could skip.
*
* TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276
*/
import { writable, get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings";
import { getDeviceId } from "$lib/services/deviceId";
import { auth } from "./auth";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Profiles");
interface ProfilesState {
profiles: Profile[];
isLoading: boolean;
error: string | null;
}
function createProfilesStore() {
const { subscribe, set, update } = writable<ProfilesState>({
profiles: [],
isLoading: false,
error: null,
});
async function refresh(): Promise<Profile[]> {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
const profiles = await commands.profilesList();
set({ profiles, isLoading: false, error: null });
return profiles;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to list profiles:", error);
set({ profiles: [], isLoading: false, error: message });
return [];
}
}
/**
* Where the app should go on launch. Asked, never computed the answer
* depends on PIN presence and a stored setting, neither of which the frontend
* should be reasoning about.
*/
async function startupTarget(): Promise<StartupTarget> {
return await commands.profilesStartupTarget();
}
/**
* Enter a profile. On success the backend has already switched; this only
* rebuilds the frontend's repository handle.
*/
async function unlock(userId: string, pin: string | null): Promise<UnlockOutcome> {
const outcome = await commands.profilesUnlock(userId, pin);
if (outcome.type === "ok") {
await auth.adoptSwitchedSession();
await refresh();
}
return outcome;
}
/**
* The way back in for someone who has forgotten their PIN. Their ordinary
* Jellyfin password is the authority over their own account, so there is
* nothing else to reset.
*/
async function unlockWithPassword(userId: string, password: string): Promise<UnlockOutcome> {
const deviceId = await getDeviceId();
const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId);
if (outcome.type === "ok") {
await auth.adoptSwitchedSession();
await refresh();
}
return outcome;
}
/** Add another account from the server already connected. */
async function add(username: string, password: string, pin: string | null): Promise<Profile> {
const deviceId = await getDeviceId();
const profile = await commands.profilesAdd(username, password, pin, deviceId);
await refresh();
return profile;
}
async function setPin(userId: string, currentPin: string | null, newPin: string | null) {
await commands.profilesSetPin(userId, currentPin, newPin);
await refresh();
}
async function remove(userId: string) {
await commands.profilesRemove(userId);
await refresh();
}
async function setAskOnStart(enabled: boolean) {
await commands.profilesSetAskOnStart(enabled);
}
/** Whether this device has more than one account — what makes the UI worth showing at all. */
function isShared(): boolean {
return get({ subscribe }).profiles.length > 1;
}
return {
subscribe,
refresh,
startupTarget,
unlock,
unlockWithPassword,
add,
setPin,
remove,
setAskOnStart,
isShared,
};
}
export const profiles = createProfilesStore();
+21 -1
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect } from "vitest";
import { formatDuration } from "./duration";
import { formatDuration, formatSecondsDuration } from "./duration";
describe("formatDuration", () => {
it("should format duration from milliseconds (mm:ss format)", () => {
@@ -39,3 +39,23 @@ describe("formatDuration", () => {
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
});
});
describe("formatSecondsDuration", () => {
it("should format duration from seconds (mm:ss format)", () => {
expect(formatSecondsDuration(1)).toBe("0:01");
expect(formatSecondsDuration(60)).toBe("1:00");
expect(formatSecondsDuration(61)).toBe("1:01");
expect(formatSecondsDuration(3661)).toBe("61:01");
});
it("should format duration with hh:mm:ss format", () => {
expect(formatSecondsDuration(3600, "hh:mm:ss")).toBe("1:00:00");
expect(formatSecondsDuration(3661, "hh:mm:ss")).toBe("1:01:01");
expect(formatSecondsDuration(7325, "hh:mm:ss")).toBe("2:02:05");
});
it("should pad minutes and seconds with leading zeros", () => {
expect(formatSecondsDuration(5, "hh:mm:ss")).toBe("0:00:05");
expect(formatSecondsDuration(65, "hh:mm:ss")).toBe("0:01:05");
});
});
+25 -13
View File
@@ -12,23 +12,11 @@
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no duration
*/
export function formatDuration(
ms?: number | null,
format: "mm:ss" | "hh:mm:ss" | "h m" = "mm:ss",
): string {
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ms) return "";
const totalSeconds = Math.floor(ms / 1000);
// "1h 23m" / "45m" — the shape a runtime is read at a glance, as opposed to
// the clock shape a *position* is read at. Three components had hand-rolled
// this identically; it belongs here with the other two.
if (format === "h m") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -42,3 +30,27 @@ export function formatDuration(
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Convert seconds to formatted duration string
* @param seconds Duration in seconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string
*/
export function formatSecondsDuration(
seconds: number,
format: "mm:ss" | "hh:mm:ss" = "mm:ss",
): string {
if (format === "hh:mm:ss") {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
// Default "mm:ss" format
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
-34
View File
@@ -202,37 +202,3 @@ describe("showHeaderSearch", () => {
expect(showHeaderSearch({ pathname: "/settings" })).toBe(false);
});
});
/**
* The profile picker is a gate, not a page. It must be chrome-free for the same
* reason /login is: bottom nav over a "who's watching" screen lets someone tab
* straight past the profile they were being asked to choose, and an in-app mini
* player on it would offer a route into the previous profile's queue. OS
* lockscreen transport controls are unaffected those are what keep playing
* audio controllable while the app is locked (DR-275).
*
* TRACES: UR-082 | DR-276
*/
describe("profile picker chrome", () => {
const pathname = "/profiles";
it("shows no bottom nav, even authenticated", () => {
expect(showBottomNav({ pathname, isAuthenticated: true })).toBe(false);
});
it("shows no global mini player", () => {
expect(showGlobalMiniPlayer({ pathname })).toBe(false);
});
it("shows no global header", () => {
expect(showGlobalHeader({ pathname, isAuthenticated: true })).toBe(false);
});
it("owns its own layout", () => {
expect(routeOwnsLayout({ pathname })).toBe(true);
});
it("lets the shell reserve the bottom inset, since nothing else will", () => {
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
});
});
+5 -17
View File
@@ -28,19 +28,10 @@ export interface BottomUiVisibilityInput {
/**
* The bottom nav is shown on every authenticated route except the full-screen
* player, the login route, and the profile picker.
*
* `/profiles` is grouped with `/login` throughout this module because it is a
* gate rather than a page: a nav bar over "who's watching" lets someone tab
* straight past the choice they were being asked to make.
* player and the login route.
*/
export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean {
return (
isAuthenticated &&
!pathname.startsWith("/player/") &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/profiles")
);
return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login");
}
/**
@@ -53,7 +44,6 @@ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolea
return (
!pathname.startsWith("/player/") &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/profiles") &&
!pathname.startsWith("/settings")
);
}
@@ -68,8 +58,7 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
return (
pathname.startsWith("/library") ||
pathname.startsWith("/player/") ||
pathname.startsWith("/login") ||
pathname.startsWith("/profiles")
pathname.startsWith("/login")
);
}
@@ -80,7 +69,7 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
* Routes that own their layout (library) render their own AppHeader, so the
* root must not double it up. `/settings` owns its content but deliberately has
* no account menu (the user is already there). `/player/*` and `/login` are
* immersive/chrome-free, as is `/profiles`. Everything else authenticated (`/`, `/search`,
* immersive/chrome-free. Everything else authenticated (`/`, `/search`,
* `/downloads`) gets the header from the root the whole point of UR-054.
*
* TRACES: UR-054 | DR-076
@@ -120,8 +109,7 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
* because the padding belongs *inside* its surface box so the colour extends
* behind the bar rather than leaving a strip of page background. On routes with
* no bottom UI at all (login, the profile picker, the full-screen player)
* nothing else would, so
* no bottom UI at all (login, the full-screen player) nothing else would, so
* the shell takes it.
*
* TRACES: UR-066 | DR-112
-1
View File
@@ -20,7 +20,6 @@ const KIND_LABELS: Record<MediaKind, string> = {
channel: "Channel",
liveChannel: "Live TV",
channelItem: "Channel",
channelFolder: "Channel",
folder: "Folder",
other: "",
};
-94
View File
@@ -1,94 +0,0 @@
import { describe, it, expect } from "vitest";
import { validatePinForm, pinActionLabel, type PinFormState } from "./pinForm";
function form(overrides: Partial<PinFormState> = {}): PinFormState {
return {
intent: "set",
hasPin: false,
currentPin: "",
newPin: "",
confirmPin: "",
...overrides,
};
}
describe("validatePinForm", () => {
it("accepts a well-formed new PIN", () => {
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1234" }))).toBeNull();
});
it("requires the current PIN whenever one is set", () => {
expect(
validatePinForm(form({ intent: "change", hasPin: true, newPin: "5678", confirmPin: "5678" })),
).toBe("Enter your current PIN.");
});
it("does not ask for a current PIN when none is set", () => {
expect(validatePinForm(form({ hasPin: false, newPin: "1234", confirmPin: "1234" }))).toBeNull();
});
it("rejects non-digits", () => {
expect(validatePinForm(form({ newPin: "12a4", confirmPin: "12a4" }))).toBe(
"A PIN can only contain digits.",
);
});
it("enforces the length range at both ends", () => {
expect(validatePinForm(form({ newPin: "123", confirmPin: "123" }))).toBe(
"A PIN must be 48 digits.",
);
expect(validatePinForm(form({ newPin: "123456789", confirmPin: "123456789" }))).toBe(
"A PIN must be 48 digits.",
);
});
it("catches a mistyped confirmation", () => {
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1235" }))).toBe(
"The two PINs do not match.",
);
});
it("asks for confirmation before comparing", () => {
expect(validatePinForm(form({ newPin: "1234", confirmPin: "" }))).toBe("Confirm your new PIN.");
});
it("rejects a change that changes nothing", () => {
expect(
validatePinForm(
form({
intent: "change",
hasPin: true,
currentPin: "1234",
newPin: "1234",
confirmPin: "1234",
}),
),
).toBe("The new PIN is the same as the current one.");
});
it("needs only the current PIN to turn one off", () => {
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "1234" }))).toBeNull();
});
it("will not turn off a PIN without the current one", () => {
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "" }))).toBe(
"Enter your current PIN.",
);
});
it("reports one problem at a time, most blocking first", () => {
// Both the current PIN is missing *and* the new one is too short; the
// current-PIN prompt is the one that comes back.
expect(
validatePinForm(form({ intent: "change", hasPin: true, newPin: "1", confirmPin: "2" })),
).toBe("Enter your current PIN.");
});
});
describe("pinActionLabel", () => {
it("names each intent", () => {
expect(pinActionLabel("set")).toBe("Set PIN");
expect(pinActionLabel("change")).toBe("Change PIN");
expect(pinActionLabel("clear")).toBe("Turn off PIN");
});
});
-85
View File
@@ -1,85 +0,0 @@
/**
* Validation for the PIN settings form.
*
* Pure, and separate from the component, so the rules can be tested without
* mounting anything the same reason `episodeStrip.ts` exists.
*
* This is *not* the security boundary. Rust validates the PIN shape again in
* `pin::validate_pin` and is the only thing that ever compares one. What lives
* here is the difference between a form that tells you what is wrong before you
* submit and one that bounces you off a backend error.
*
* TRACES: UR-083 | DR-268, DR-276
*/
/** Mirrors `pin::MAX_ATTEMPTS`-adjacent shape rules in Rust. */
export const PIN_MIN_LENGTH = 4;
export const PIN_MAX_LENGTH = 8;
export type PinIntent = "set" | "change" | "clear";
export interface PinFormState {
intent: PinIntent;
/** Whether the profile currently has a PIN — decides if `currentPin` is required. */
hasPin: boolean;
currentPin: string;
newPin: string;
confirmPin: string;
}
/**
* The reason this form cannot be submitted yet, or `null` when it can.
*
* Returns one message rather than a list: a PIN form has at most one thing
* wrong with it worth saying, and stacking "too short" under "doesn't match"
* reads as nagging.
*
* TRACES: UR-083 | DR-276
*/
export function validatePinForm(state: PinFormState): string | null {
if (state.hasPin && state.currentPin.length === 0) {
return "Enter your current PIN.";
}
if (state.intent === "clear") {
return null;
}
if (state.newPin.length === 0) {
return "Choose a PIN.";
}
if (!/^[0-9]+$/.test(state.newPin)) {
return "A PIN can only contain digits.";
}
if (state.newPin.length < PIN_MIN_LENGTH || state.newPin.length > PIN_MAX_LENGTH) {
return `A PIN must be ${PIN_MIN_LENGTH}${PIN_MAX_LENGTH} digits.`;
}
if (state.confirmPin.length === 0) {
return "Confirm your new PIN.";
}
if (state.newPin !== state.confirmPin) {
return "The two PINs do not match.";
}
if (state.intent === "change" && state.currentPin === state.newPin) {
return "The new PIN is the same as the current one.";
}
return null;
}
/**
* What the button should say. Derived rather than hardcoded per branch so the
* three intents cannot drift apart in the markup.
*
* TRACES: UR-083 | DR-276
*/
export function pinActionLabel(intent: PinIntent): string {
switch (intent) {
case "set":
return "Set PIN";
case "change":
return "Change PIN";
case "clear":
return "Turn off PIN";
}
}
-98
View File
@@ -1,98 +0,0 @@
import { describe, it, expect } from "vitest";
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "./profileTiles";
import type { Profile } from "$lib/api/bindings";
function profile(overrides: Partial<Profile> = {}): Profile {
return {
userId: "u1",
username: "Dad",
serverId: "s1",
avatarTag: null,
unlockMethod: "none",
lastUsedAt: null,
isActive: false,
...overrides,
};
}
describe("orderProfiles", () => {
it("puts the most recently used profile first", () => {
const ordered = orderProfiles([
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.userId)).toEqual(["b", "a"]);
});
it("sorts never-used profiles after used ones, then by name", () => {
const ordered = orderProfiles([
profile({ userId: "z", username: "Zoe", lastUsedAt: null }),
profile({ userId: "a", username: "Ann", lastUsedAt: null }),
profile({ userId: "u", username: "Used", lastUsedAt: "2026-01-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.username)).toEqual(["Used", "Ann", "Zoe"]);
});
it("does not mutate its input", () => {
const input = [
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
];
orderProfiles(input);
expect(input.map((p) => p.userId)).toEqual(["a", "b"]);
});
});
describe("initialsFor", () => {
it("takes two letters from a single name", () => {
expect(initialsFor("Dad")).toBe("DA");
});
it("takes first and last initials from a multi-part name", () => {
expect(initialsFor("Anna Marie Smith")).toBe("AS");
});
it("splits on the separators usernames actually use", () => {
expect(initialsFor("anna_smith")).toBe("AS");
expect(initialsFor("anna.smith")).toBe("AS");
expect(initialsFor("anna-smith")).toBe("AS");
});
it("survives an empty or whitespace name", () => {
expect(initialsFor("")).toBe("?");
expect(initialsFor(" ")).toBe("?");
});
});
describe("tileColour", () => {
it("is stable for the same user", () => {
expect(tileColour("user-123")).toBe(tileColour("user-123"));
});
it("is derived from the id, not the name, so renaming keeps the colour", () => {
// Same id, different display names — the caller only passes the id, which is
// the point: a rename cannot move someone's tile colour.
expect(tileColour("user-123")).toBe(tileColour("user-123"));
expect(tileColour("user-456")).not.toBe("");
});
});
describe("lockoutMessage", () => {
const now = new Date("2026-01-01T00:00:00Z");
it("rounds up to whole minutes", () => {
expect(lockoutMessage("2026-01-01T00:02:30Z", now)).toBe("Try again in 3 minutes");
});
it("phrases under a minute without a number", () => {
expect(lockoutMessage("2026-01-01T00:00:30Z", now)).toBe("Try again in less than a minute");
});
it("treats an elapsed lockout as over", () => {
expect(lockoutMessage("2025-12-31T23:59:00Z", now)).toBe("Try again now");
});
it("does not render NaN when the timestamp is unusable", () => {
expect(lockoutMessage("not-a-date", now)).toBe("Try again now");
});
});
-76
View File
@@ -1,76 +0,0 @@
/**
* Presentation helpers for the profile picker.
*
* Pure, and separate from the component, because this is the half worth testing
* and a component cannot be unit-tested without mounting it.
*
* Note what is *not* here: nothing decides whether a profile is locked, whether
* a PIN is correct, or how many attempts remain. Those come from the backend as
* an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply
* one with no PIN the app models no roles and infers no ages.
*
* TRACES: UR-082, UR-083 | DR-276
*/
import type { Profile } from "$lib/api/bindings";
/**
* Order tiles as people expect to find them: whoever used the device last is
* leftmost, and profiles that have never been used sort after those that have.
*
* TRACES: UR-082 | DR-276
*/
export function orderProfiles(profiles: Profile[]): Profile[] {
return [...profiles].sort((a, b) => {
if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt);
if (a.lastUsedAt) return -1;
if (b.lastUsedAt) return 1;
return a.username.localeCompare(b.username);
});
}
/**
* Initials for a tile with no avatar. At most two letters, because three fills
* a circle badly at tile size.
*
* TRACES: UR-082 | DR-276
*/
export function initialsFor(username: string): string {
const words = username
.trim()
.split(/[\s._-]+/)
.filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/**
* A stable tile colour per profile, so a family learns to recognise their tile
* by colour before they read the name. Derived from the user id rather than the
* name, so renaming does not move someone's colour.
*
* TRACES: UR-082 | DR-276
*/
const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const;
export function tileColour(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
}
return TILE_COLOURS[hash % TILE_COLOURS.length];
}
/**
* How long a lockout has left, phrased for a person rather than a log line.
*
* TRACES: UR-083 | DR-276
*/
export function lockoutMessage(until: string, now: Date = new Date()): string {
const remainingMs = new Date(until).getTime() - now.getTime();
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now";
const minutes = Math.ceil(remainingMs / 60000);
if (minutes <= 1) return "Try again in less than a minute";
return `Try again in ${minutes} minutes`;
}
+4 -25
View File
@@ -3,7 +3,6 @@
import { goto } from "$app/navigation";
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated } from "$lib/stores/auth";
import { profiles } from "$lib/stores/profiles";
import { home } from "$lib/stores/home";
import { library, libraries } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
@@ -30,31 +29,11 @@
let previousServerReachable = false;
let isAndroid = $state(false);
// Where an unauthenticated app goes depends on what this device holds. A
// single account with no PIN goes straight to login exactly as before; a
// device with several profiles, or one whose last profile is PIN-protected,
// goes to the picker instead. The decision is the backend's — the frontend
// asks rather than counting profiles itself, because it also turns on a stored
// setting and on which profiles have a PIN. (DR-274)
let routingAway = false;
// Redirect to login if not authenticated
$effect(() => {
if ($isAuthenticated || routingAway) return;
routingAway = true;
void (async () => {
try {
const target = await profiles.startupTarget();
const found = await profiles.refresh();
// The picker is only a picker when there is something to pick. With no
// profiles stored it would render an empty room, so first run still
// goes to login.
await goto(target.type === "picker" && found.length > 0 ? "/profiles" : "/login");
} catch (error) {
log.error("Could not resolve startup target:", error);
await goto("/login");
} finally {
routingAway = false;
}
})();
if (!$isAuthenticated) {
goto("/login");
}
});
// Load home sections when authenticated
-1
View File
@@ -155,7 +155,6 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
// Navigate to detail view
+14 -7
View File
@@ -1,7 +1,6 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
@@ -171,10 +170,7 @@
}
}
// Name the container so Rust can order its children: a podcast (a channel
// folder) is listed newest episode first, everything else by name.
// TRACES: UR-007 | DR-257
await library.loadItems(itemId, { limit: 100, parentKind: item?.kind });
await library.loadItems(itemId, { limit: 100 });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
@@ -254,6 +250,18 @@
// Images now handled by CachedImage component
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
@@ -280,7 +288,6 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
case "movie":
@@ -527,7 +534,7 @@
>
{/if}
{#if item.durationMs}
<span>{formatDuration(item.durationMs, "h m")}</span>
<span>{formatDuration(item.durationMs)}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">
-1
View File
@@ -174,7 +174,6 @@
"series",
"season",
"folder",
"channelFolder",
"playlist",
"channel",
];
-451
View File
@@ -1,451 +0,0 @@
<!--
"Who's watching" — the profile picker.
Everything here is presentation. Which profiles exist, whether one is locked,
whether a code was right and how many guesses are left all arrive from Rust;
this page renders them. In particular there is no notion of an adult or a child
account anywhere in this file — a child's profile is simply one whose
`unlockMethod` is "none", which is one tap.
TRACES: UR-082, UR-083, UR-084 | DR-276
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { profiles } from "$lib/stores/profiles";
import { isAuthenticated } from "$lib/stores/auth";
import { navigateBack } from "$lib/utils/navigation";
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
import PinPad from "$lib/components/PinPad.svelte";
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("ProfilePicker");
type Mode = "picker" | "pin" | "password" | "add" | "manage";
let mode = $state<Mode>("picker");
let selected = $state<Profile | null>(null);
let pin = $state("");
let password = $state("");
let entryError = $state<string | null>(null);
let busy = $state(false);
// Add-profile form
let newUsername = $state("");
let newPassword = $state("");
let newPin = $state("");
let usePinForNew = $state(false);
const ordered = $derived(orderProfiles($profiles.profiles));
onMount(() => {
// Settings deep-links here for add/remove rather than duplicating the tile
// grid: the tiles are the natural place to act on a profile, and two copies
// of that list would drift.
if ($page.url.searchParams.get("manage") === "1") mode = "manage";
void profiles.refresh();
});
function reset() {
mode = "picker";
selected = null;
pin = "";
password = "";
entryError = null;
newUsername = "";
newPassword = "";
newPin = "";
usePinForNew = false;
}
function renderOutcome(outcome: UnlockOutcome): boolean {
switch (outcome.type) {
case "ok":
return true;
case "wrongPin":
pin = "";
entryError =
outcome.attemptsRemaining === 1
? "Wrong PIN. One more try before this profile locks."
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
return false;
case "lockedOut":
pin = "";
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
return false;
case "needsPassword":
mode = "password";
entryError = "Sign in with your password to continue.";
return false;
}
}
async function choose(profile: Profile) {
if (profile.unlockMethod === "pin") {
selected = profile;
pin = "";
entryError = null;
mode = "pin";
return;
}
await enter(profile, null);
}
async function enter(profile: Profile, code: string | null) {
busy = true;
entryError = null;
try {
const outcome = await profiles.unlock(profile.userId, code);
if (renderOutcome(outcome)) {
reset();
await goto("/");
}
} catch (error) {
log.error("Unlock failed:", error);
entryError = error instanceof Error ? error.message : "Could not switch profile";
} finally {
busy = false;
}
}
async function enterWithPassword(event: Event) {
event.preventDefault();
if (!selected) return;
busy = true;
entryError = null;
try {
const outcome = await profiles.unlockWithPassword(selected.userId, password);
if (renderOutcome(outcome)) {
reset();
await goto("/");
}
} catch (error) {
log.error("Password unlock failed:", error);
entryError = error instanceof Error ? error.message : "Sign-in failed";
} finally {
busy = false;
}
}
async function addProfile(event: Event) {
event.preventDefault();
busy = true;
entryError = null;
try {
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
reset();
} catch (error) {
log.error("Add profile failed:", error);
entryError = error instanceof Error ? error.message : "Could not add profile";
} finally {
busy = false;
}
}
async function removeProfile(profile: Profile) {
busy = true;
entryError = null;
try {
await profiles.remove(profile.userId);
} catch (error) {
entryError = error instanceof Error ? error.message : "Could not remove profile";
} finally {
busy = false;
}
}
</script>
<div class="min-h-full flex items-center justify-center p-6">
<div class="w-full max-w-3xl">
<!--
Reached from the account menu, the picker must not be a one-way door: a
signed-in viewer who opens it and changes their mind (or fails a PIN on
someone else's tile) needs a way back to the session they still have. At
startup there is no session behind it, so there is nothing to go back to
and this stays hidden. (DR-276)
-->
{#if $isAuthenticated && mode !== "pin" && mode !== "password"}
<button
type="button"
onclick={() => navigateBack("/")}
class="mb-6 text-sm text-gray-400 hover:text-white flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Back
</button>
{/if}
{#if mode === "picker" || mode === "manage"}
<h1 class="text-3xl font-semibold text-center mb-2">
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
</h1>
<p class="text-gray-400 text-center mb-10 text-sm">
{#if mode === "manage"}
Removing a profile only affects this device. It does not sign that person out elsewhere.
{:else}
Everyone here signs in to the same server.
{/if}
</p>
{#if $profiles.error}
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{$profiles.error}
</div>
{/if}
{#if entryError}
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex flex-wrap justify-center gap-8">
{#each ordered as profile (profile.userId)}
<div class="flex flex-col items-center gap-3">
<button
type="button"
onclick={() => (mode === "manage" ? undefined : choose(profile))}
disabled={busy || mode === "manage"}
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
style="background-color: {tileColour(profile.userId)}"
aria-label="Switch to {profile.username}"
>
{initialsFor(profile.username)}
{#if profile.unlockMethod === "pin"}
<span
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
aria-label="PIN required"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</span>
{/if}
</button>
<span class="text-sm text-gray-300">{profile.username}</span>
{#if mode === "manage" && !profile.isActive}
<button
type="button"
onclick={() => removeProfile(profile)}
disabled={busy}
class="text-xs text-red-400 hover:text-red-300"
>
Remove
</button>
{:else if mode === "manage"}
<span class="text-xs text-gray-500">In use</span>
{/if}
</div>
{/each}
{#if mode === "picker"}
<div class="flex flex-col items-center gap-3">
<button
type="button"
onclick={() => {
entryError = null;
mode = "add";
}}
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
aria-label="Add a profile"
>
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<span class="text-sm text-gray-500">Add profile</span>
</div>
{/if}
</div>
<div class="text-center mt-12">
<button
type="button"
onclick={() => {
entryError = null;
mode = mode === "manage" ? "picker" : "manage";
}}
class="text-sm text-gray-400 hover:text-white"
>
{mode === "manage" ? "Done" : "Manage profiles"}
</button>
</div>
{:else if mode === "pin" && selected}
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-3">
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
style="background-color: {tileColour(selected.userId)}"
>
{initialsFor(selected.username)}
</div>
<h1 class="text-xl font-medium">{selected.username}</h1>
</div>
<PinPad
bind:value={pin}
error={entryError}
disabled={busy}
onsubmit={(code) => selected && enter(selected, code)}
oncancel={reset}
/>
<button
type="button"
onclick={() => {
entryError = null;
password = "";
mode = "password";
}}
class="text-sm text-gray-400 hover:text-white underline"
>
Forgot your PIN? Use your password
</button>
</div>
{:else if mode === "password" && selected}
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
<h1 class="text-2xl font-semibold text-center mb-6">
Sign in as {selected.username}
</h1>
<div>
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="profile-password"
type="password"
bind:value={password}
autofocus
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
{#if entryError}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex gap-3">
<button
type="button"
onclick={reset}
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
>
Cancel
</button>
<button
type="submit"
disabled={busy || !password}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
>
Sign in
</button>
</div>
</form>
{:else if mode === "add"}
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
<p class="text-gray-400 text-sm text-center mb-6">
Another account on the server you are already connected to.
</p>
<div>
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
id="new-username"
type="text"
bind:value={newUsername}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
<label class="flex items-start gap-3 text-sm text-gray-300">
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
<span>
Protect this profile with a PIN
<span class="block text-gray-500 text-xs mt-1">
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
switch to an account — what each account may watch is set on the Jellyfin server.
</span>
</span>
</label>
{#if usePinForNew}
<div>
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
PIN (48 digits)
</label>
<input
id="new-pin"
type="password"
inputmode="numeric"
bind:value={newPin}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
disabled={busy}
/>
</div>
{/if}
{#if entryError}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex gap-3">
<button
type="button"
onclick={reset}
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
>
Cancel
</button>
<button
type="submit"
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
>
Add
</button>
</div>
</form>
{/if}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More