Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdcdca47e | ||
|
|
6e16f188dc |
@@ -89,15 +89,6 @@ jobs:
|
|||||||
# sit on master until somebody cut a tag. These three steps are what make
|
# sit on master until somebody cut a tag. These three steps are what make
|
||||||
# those configs load-bearing. All are project deps installed by
|
# those configs load-bearing. All are project deps installed by
|
||||||
# `bun install`; nothing is fetched at job time.
|
# `bun install`; nothing is fetched at job time.
|
||||||
# Cheap tripwire for a class of defect this repo kept hitting: tooling on
|
|
||||||
# a rarely-taken path. scripts/build-android.sh ran `npm install` on its
|
|
||||||
# clean-build branch -- in a bun project, ignoring bun.lock and
|
|
||||||
# re-resolving the tree, which is how the Tauri plugin crate/package
|
|
||||||
# versions drifted apart and broke a release build. It survived because
|
|
||||||
# clean builds are rare.
|
|
||||||
- name: Check build tooling
|
|
||||||
run: bash scripts/check-tooling.sh
|
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: bun run format:check
|
run: bun run format:check
|
||||||
|
|
||||||
@@ -109,35 +100,13 @@ jobs:
|
|||||||
# at "warn" until its class is cleared and it can be promoted to "error".
|
# at "warn" until its class is cleared and it can be promoted to "error".
|
||||||
# Lower this as you clear them. Never raise it to make a build pass.
|
# Lower this as you clear them. Never raise it to make a build pass.
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: bun run lint -- --max-warnings=158
|
run: bun run lint -- --max-warnings=159
|
||||||
|
|
||||||
- name: Check TypeScript
|
- name: Check TypeScript
|
||||||
run: |
|
run: |
|
||||||
bunx svelte-kit sync
|
bunx svelte-kit sync
|
||||||
bun run check
|
bun run check
|
||||||
|
|
||||||
# Tauri refuses to build when a plugin's Rust crate and npm package are on
|
|
||||||
# different minor versions. Nothing here runs `tauri build` -- that only
|
|
||||||
# happens on a tag -- so a mismatch introduced on master stayed invisible
|
|
||||||
# until the release build, which is where it was found: v0.10.0 prep hit
|
|
||||||
# `tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)`. `cargo
|
|
||||||
# check`, clippy, the tests and svelte-check had all passed.
|
|
||||||
#
|
|
||||||
# `tauri info` performs the same comparison the bundler does, without a
|
|
||||||
# build. Grepping its output is crude, but the alternative is discovering
|
|
||||||
# this at tag time again.
|
|
||||||
- name: Check Tauri plugin versions match
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
if bunx tauri info 2>&1 | tee /tmp/tauri-info.txt | grep -q "version mismatched"; then
|
|
||||||
echo "::error::A Tauri plugin's Rust crate and npm package versions disagree."
|
|
||||||
echo "::error::The release build will refuse to start. Align them in"
|
|
||||||
echo "::error::src-tauri/Cargo.toml and package.json (both are pinned exactly)."
|
|
||||||
grep -A6 "version mismatched" /tmp/tauri-info.txt || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Tauri plugin crate/package versions agree."
|
|
||||||
|
|
||||||
# Coverage rather than a bare `bun run test`: same suite, plus the
|
# Coverage rather than a bare `bun run test`: same suite, plus the
|
||||||
# thresholds in vitest.config.ts, so a large untested module or a deleted
|
# thresholds in vitest.config.ts, so a large untested module or a deleted
|
||||||
# test fails here instead of being noticed months later.
|
# test fails here instead of being noticed months later.
|
||||||
|
|||||||
@@ -146,29 +146,9 @@ jobs:
|
|||||||
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
||||||
# than quietly shipping an unsigned release that no client will accept --
|
# than quietly shipping an unsigned release that no client will accept --
|
||||||
# which is the behaviour we want.
|
# which is the behaviour we want.
|
||||||
# Same hazard as the Windows job: the bundle directory is never cleaned by
|
|
||||||
# cargo and the runner reuses src-tauri/target, while the copy step below
|
|
||||||
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
|
|
||||||
# (v0.8.2 shipped thirteen stale installers), but only because Linux
|
|
||||||
# packaging is newer -- the glob is identical. Remove the directory so a
|
|
||||||
# stale artifact cannot exist to be copied.
|
|
||||||
- name: Clear previous bundle output
|
|
||||||
run: rm -rf src-tauri/target/release/bundle
|
|
||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
run: bun run tauri build
|
run: bun run tauri build
|
||||||
env:
|
env:
|
||||||
# linuxdeploy's bundled `strip` cannot parse the `.relr.dyn` section
|
|
||||||
# modern toolchains emit, and fails on every bundled library:
|
|
||||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
|
||||||
# failed to bundle project `failed to run linuxdeploy`
|
|
||||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so this
|
|
||||||
# image hits it. Skipping strip is linuxdeploy's documented escape
|
|
||||||
# hatch; the cost is a larger AppImage. Found by building the target
|
|
||||||
# locally before tagging -- nothing in CI builds the app, so a release
|
|
||||||
# would have been the first time anyone discovered the AppImage target
|
|
||||||
# does not work.
|
|
||||||
NO_STRIP: "true"
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
|
||||||
@@ -377,12 +357,8 @@ jobs:
|
|||||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# `--apk` is a boolean flag, not `--apk true`. tauri-cli took a value here
|
|
||||||
# until 2.10; from 2.11 the stray `true` is parsed as a positional and the
|
|
||||||
# command fails with "unexpected argument 'true' found" before building.
|
|
||||||
# This line and scripts/build-android.sh must agree.
|
|
||||||
- name: Build signed Android APK
|
- name: Build signed Android APK
|
||||||
run: bun run tauri android build --apk --target aarch64
|
run: bun run tauri android build --apk true --target aarch64
|
||||||
|
|
||||||
- name: Collect & verify signed APK
|
- name: Collect & verify signed APK
|
||||||
run: |
|
run: |
|
||||||
@@ -437,19 +413,6 @@ jobs:
|
|||||||
name: jellytau-android
|
name: jellytau-android
|
||||||
path: artifacts/android/
|
path: artifacts/android/
|
||||||
|
|
||||||
# Runs before the SBOM, the checksums and the upload -- everything
|
|
||||||
# downstream describes this set of files, so a stale artifact must be
|
|
||||||
# caught before it gets hashed into SHA256SUMS and published as though it
|
|
||||||
# belonged to this release.
|
|
||||||
#
|
|
||||||
# See the script for the eight months of releases that shipped their
|
|
||||||
# predecessors' Windows installers.
|
|
||||||
- name: Verify artifacts belong to this release
|
|
||||||
run: |
|
|
||||||
./scripts/check-release-artifacts.sh \
|
|
||||||
"${{ steps.tag_name.outputs.VERSION }}" \
|
|
||||||
artifacts/linux artifacts/windows artifacts/android
|
|
||||||
|
|
||||||
# Software Bill of Materials, one per half of the app. Without it there is
|
# Software Bill of Materials, one per half of the app. Without it there is
|
||||||
# no answer to "does this release contain <vulnerable crate>?" other than
|
# no answer to "does this release contain <vulnerable crate>?" other than
|
||||||
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
||||||
@@ -519,11 +482,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# What the in-app update prompt shows. Same reviewed source as the
|
# Release notes for the update prompt come from the traceability graph,
|
||||||
# release body -- the CHANGELOG section for this version, not the
|
# same source as the release body.
|
||||||
# traceability draft.
|
NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")"
|
||||||
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
|
|
||||||
[ -n "$NOTES" ] || NOTES="See the release page for details."
|
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg version "$PLAIN" \
|
--arg version "$PLAIN" \
|
||||||
@@ -588,37 +549,24 @@ jobs:
|
|||||||
# release rather than shipping and failing for users.
|
# release rather than shipping and failing for users.
|
||||||
sha256sum -c SHA256SUMS
|
sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
# The published body is the hand-written CHANGELOG.md section for this
|
# Release notes come from the traceability graph, not from a hardcoded
|
||||||
# version. `bun run release:notes` is printed into the job log as a
|
# heredoc. scripts/release-notes.ts resolves the commit range's changed
|
||||||
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
|
# files to their TRACES ids and then to requirement descriptions, grouping
|
||||||
# output is "a reviewed draft, not a final changelog", and publishing it
|
# UR into Features and DR/IR into Improvements -- which is what CLAUDE.md
|
||||||
# unreviewed proved the point -- a range containing a repo-wide prettier
|
# has asked for all along, while this workflow pasted a fixed block of
|
||||||
# sweep resolved to nearly the whole requirement matrix and produced notes
|
# install instructions and a line saying "see CHANGELOG.md for detailed
|
||||||
# claiming one release had added the entire application.
|
# changes". It also linked "GitHub Issues" on a Gitea-hosted project.
|
||||||
#
|
|
||||||
# A missing CHANGELOG section fails the release. A release whose notes say
|
|
||||||
# nothing is worse than one that waits for a maintainer to write two
|
|
||||||
# sentences, and the checklist already requires that entry.
|
|
||||||
- name: Prepare release notes
|
- name: Prepare release notes
|
||||||
id: release_notes
|
id: release_notes
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||||
|
|
||||||
echo "📋 Traceability draft (for reference; not published):"
|
|
||||||
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# The section between this version's heading and the next one.
|
|
||||||
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
|
|
||||||
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
|
|
||||||
echo "::error::CHANGELOG.md has no '## $VERSION' section."
|
|
||||||
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "$CHANGES"
|
echo "## JellyTau $VERSION"
|
||||||
|
echo ""
|
||||||
|
# A generated summary of what actually changed; falls back to a
|
||||||
|
# pointer rather than failing the release if the range is odd.
|
||||||
|
bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### Downloads"
|
echo "### Downloads"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -630,8 +578,8 @@ jobs:
|
|||||||
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
||||||
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Desktop builds check for updates from here and can install a new"
|
echo "Desktop builds update themselves from here on: JellyTau checks this"
|
||||||
echo "version in place, verifying its signature first."
|
echo "release feed and can install a new version in place."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### Verifying your download"
|
echo "### Verifying your download"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -651,7 +599,6 @@ jobs:
|
|||||||
echo "---"
|
echo "---"
|
||||||
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
||||||
} > release_notes.md
|
} > release_notes.md
|
||||||
|
|
||||||
echo "📝 Release notes:"
|
echo "📝 Release notes:"
|
||||||
cat release_notes.md
|
cat release_notes.md
|
||||||
|
|
||||||
|
|||||||
@@ -9,105 +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
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[docs/defect-windows.md](docs/defect-windows.md).
|
||||||
|
|
||||||
## v0.10.0
|
|
||||||
|
|
||||||
Two things you can see, and a great deal of work on how this project builds and
|
|
||||||
ships itself. The app can now update itself, and it can tell you what it did
|
|
||||||
when something goes wrong — both of which existed as gaps rather than as bugs,
|
|
||||||
which is why they lasted so long.
|
|
||||||
|
|
||||||
### ✨ Changes
|
|
||||||
|
|
||||||
- **JellyTau can update itself.** Anyone who installed an AppImage or ran the
|
|
||||||
Windows installer was frozen on that version permanently: nothing in the app
|
|
||||||
ever mentioned that a newer one existed, and the release page was the only
|
|
||||||
announcement. Settings → Updates now checks, shows what changed, and installs
|
|
||||||
and restarts on request. Each download is verified against JellyTau's signing
|
|
||||||
key before anything is installed, so a substituted file is refused rather than
|
|
||||||
run. Android is deliberately not wired to this — an app may not replace its own
|
|
||||||
APK, that is the system installer's job — and is given a link to the releases
|
|
||||||
page instead of a button that would fail. (UR-077 → DR-217)
|
|
||||||
|
|
||||||
- **You can export a diagnostics bundle.** Until now the app forgot everything it
|
|
||||||
had done the moment it closed. Logs went to standard output, which nobody sees
|
|
||||||
when launching from a desktop icon, and on Android went nowhere at all — so the
|
|
||||||
backend was invisible on the platform where the hardest playback bugs live. A
|
|
||||||
crash left nothing behind. Logs are now kept in a size-capped file that
|
|
||||||
survives a restart, a crash is recorded before the app dies, and Settings →
|
|
||||||
Diagnostics exports the lot as one file to attach to a bug report. Access
|
|
||||||
tokens and passwords are stripped before anything is written to disk, not
|
|
||||||
merely before it is exported. Nothing is transmitted anywhere; you attach the
|
|
||||||
file yourself. (UR-078 → DR-218)
|
|
||||||
|
|
||||||
- **Linux gets an AppImage again.** The release notes have advertised one for
|
|
||||||
months while the build never produced it — the packaging step looked for the
|
|
||||||
file, found nothing, and said nothing. (DR-217)
|
|
||||||
|
|
||||||
### 🐛 Fixes
|
|
||||||
|
|
||||||
- **Releases no longer ship every Windows installer ever built.** Every release
|
|
||||||
from v0.1.0 to v0.8.2 carried its predecessors': sixteen installers on v0.8.2,
|
|
||||||
thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0.
|
|
||||||
The build directory is never cleaned and the build machine reuses it, so each
|
|
||||||
release collected whatever was left behind. It went unnoticed for eight months
|
|
||||||
because nothing looked wrong — the files were real and the page merely looked
|
|
||||||
busy. The stale files have been removed from the published releases, the build
|
|
||||||
now clears that directory first, and a check refuses to publish a release
|
|
||||||
containing an artifact from a different version. (DR-220)
|
|
||||||
|
|
||||||
- **Release notes now say what changed.** All 35 previous releases published the
|
|
||||||
same block of generic install instructions, whose "What's New" section was a
|
|
||||||
link to a file that does not resolve from a release page. Every release page
|
|
||||||
now carries its own entry from this changelog, and the past ones have been
|
|
||||||
filled in. (DR-219)
|
|
||||||
|
|
||||||
### 🔒 Security and supply chain
|
|
||||||
|
|
||||||
- **Dependencies are now checked against a vulnerability database on every
|
|
||||||
build.** They never had been. The first run found eight vulnerabilities and one
|
|
||||||
unsoundness in the Rust dependency graph — all of them fixed by an update
|
|
||||||
nobody had a reason to run. Licences are checked against an allow-list too, so
|
|
||||||
nothing gets redistributed inside a release that does not permit it.
|
|
||||||
(DR-216)
|
|
||||||
|
|
||||||
- **Every release publishes checksums and a bill of materials.** `SHA256SUMS`
|
|
||||||
lets you verify a download (`sha256sum -c SHA256SUMS`); the SBOM lists what
|
|
||||||
went into the build, so "does this release contain <vulnerable library>?" has
|
|
||||||
an answer that is not "rebuild it and find out". (DR-216)
|
|
||||||
|
|
||||||
- **Builds are reproducible again.** Every CI job named a container image tag
|
|
||||||
that was rewritten in place, so rebuilding an old release did not necessarily
|
|
||||||
rebuild the same thing. Jobs now pin an immutable tag. The one dependency that
|
|
||||||
comes from a git branch rather than a package registry is pinned to an exact
|
|
||||||
revision, closing a path by which new upstream code could arrive unreviewed in
|
|
||||||
a library linked into the player. (DR-216)
|
|
||||||
|
|
||||||
### 🧹 Under the hood
|
|
||||||
|
|
||||||
- Formatting, linting and type-checking now run in CI. All three were configured
|
|
||||||
and enforced by nothing: 199 files did not match the project's own formatter, a
|
|
||||||
type error could sit on the main branch until somebody cut a release, and the
|
|
||||||
test-coverage command had been broken for months by a dependency mismatch.
|
|
||||||
Coverage now has a floor that only moves up. (DR-215)
|
|
||||||
|
|
||||||
- The traceability matrix counts requirements implemented by configuration.
|
|
||||||
Several carried the necessary annotations and were being counted as uncovered
|
|
||||||
because the extraction tool only read source files. (DR-215)
|
|
||||||
|
|
||||||
- The project now has a security policy, contribution guide, code of conduct,
|
|
||||||
issue and pull-request templates, and an operations document covering the
|
|
||||||
builder image, the release secrets, and what losing the signing key would mean.
|
|
||||||
|
|
||||||
- The app framework moved from Tauri 2.9.5 to 2.11.5. Nothing about this is
|
|
||||||
visible in use, but it is worth recording that it did not go quietly: the
|
|
||||||
windowing layer beneath Tauri quietly stopped publishing the Android JavaVM
|
|
||||||
and application handle that this app's credential storage had been reading for
|
|
||||||
its whole life. Nothing here had changed; a side effect several dependencies
|
|
||||||
down had simply gone away, and the app aborted on launch on every Android
|
|
||||||
device. JellyTau now sets that handle itself rather than relying on someone
|
|
||||||
else to do it. Caught by installing on a real tablet before release — no test
|
|
||||||
suite runs the app. (UR-012 → DR-223)
|
|
||||||
|
|
||||||
## v0.9.1
|
## v0.9.1
|
||||||
|
|
||||||
A one-line fix to the home screen, released on its own because it is the kind of
|
A one-line fix to the home screen, released on its own because it is the kind of
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-log": "2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"svelte-dnd-action": "^0.9.69",
|
"svelte-dnd-action": "^0.9.69",
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
@@ -255,35 +255,35 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
"@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.9.6", "", { "os": "linux", "cpu": "arm" }, "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.9.6", "", { "os": "linux", "cpu": "none" }, "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.9.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="],
|
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
||||||
|
|
||||||
@@ -761,9 +761,9 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
"@tauri-apps/plugin-log/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||||
|
|
||||||
|
|||||||
@@ -673,151 +673,8 @@ device profile. Sending it there — not just on the transcode URL — is what m
|
|||||||
the cap real: a stream the server decides to *direct play* is served at the
|
the cap real: a stream the server decides to *direct play* is served at the
|
||||||
source file's own bitrate, and no URL parameter afterwards can reduce it.
|
source file's own bitrate, and no URL parameter afterwards can reduce it.
|
||||||
|
|
||||||
#### Two levels of ceiling
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/online.rs` (TRACES: UR-074, UR-079 | DR-225)
|
|
||||||
|
|
||||||
There are two, and they are not the same thing:
|
|
||||||
|
|
||||||
| | Set by | Lives until | Read via |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Device default** | Settings (`player_set_video_settings`) | Persisted; restored at startup | `streaming_quality()` |
|
|
||||||
| **Per-playback override** | The in-player picker (`player_set_stream_quality`) | The next item starts playing | `playback_quality_override()` |
|
|
||||||
|
|
||||||
`effective_streaming_quality()` resolves the pair — override first, else default —
|
|
||||||
and **is the only thing stream construction may read**. Every URL builder and the
|
|
||||||
`PlaybackInfo` negotiation go through it, for the reason the process-wide static
|
|
||||||
existed in the first place: if the negotiation and the URL builder disagree, the
|
|
||||||
cap leaks — the negotiation authorises a direct play the builder then never gets
|
|
||||||
to constrain, or the reverse.
|
|
||||||
|
|
||||||
> The override exists because a single global cannot express "this 4K remux needs
|
|
||||||
> a ceiling, that podcast does not". The picker had documented itself as a "this
|
|
||||||
> film, this connection" control since it was written, but was implemented by
|
|
||||||
> writing the *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. It is cleared on every `player_play_item` /
|
|
||||||
> `player_play_queue` / `player_play_tracks`, which is what stops it surviving
|
|
||||||
> into an autoplayed next episode where nobody would reopen the picker.
|
|
||||||
|
|
||||||
### Stream selection
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/stream_selection.rs`,
|
|
||||||
`OnlineRepository::get_stream_selection` (TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227)
|
|
||||||
|
|
||||||
**Rust decides *what stream*. The player decides *how to deliver it*.** That line
|
|
||||||
is the whole design. A backend with genuine adaptive selection (ExoPlayer over a
|
|
||||||
multi-variant playlist) is left to do it; Rust chooses what to request and never
|
|
||||||
paces bytes.
|
|
||||||
|
|
||||||
`get_stream_selection` returns one self-describing `StreamSelection` in place of
|
|
||||||
the bare URL `get_video_stream_url` used to hand out:
|
|
||||||
|
|
||||||
| Field | Carries |
|
|
||||||
|---|---|
|
|
||||||
| `url` | What to open |
|
|
||||||
| `transport` | `Hls` / `Progressive` / `LocalFile` — how to fetch it |
|
|
||||||
| `playback_kind` | `DirectPlay` / `DirectStream` / `Transcode` — what the server is doing to the source |
|
|
||||||
| `rendition` | The negotiated ceiling and codecs; `None` for a direct play, which *is* the source |
|
|
||||||
| `available` | The quality ladder as it applies to this media source (DR-226) |
|
|
||||||
| `needs_transcoding` | Derived from `playback_kind`, so the rule is answered once |
|
|
||||||
|
|
||||||
Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a
|
|
||||||
discriminant rather than comparing text.
|
|
||||||
|
|
||||||
> **Why `transport` exists.** `VideoPlayer.svelte` chose its loader with
|
|
||||||
> `url.includes(".m3u8")`, in two places. Rust *built* that URL and knows exactly
|
|
||||||
> what it is; re-deriving it downstream by substring match is a domain fact
|
|
||||||
> reconstructed in the presentation layer — the same class of error as leaking
|
|
||||||
> item-type taxonomy, and one that fails silently in **both** directions: a
|
|
||||||
> progressive file served from a path containing the substring gets an HLS
|
|
||||||
> loader, and a playlist served from a path without it does not.
|
|
||||||
>
|
|
||||||
> The paths that never negotiate get the same shape from Rust rather than letting
|
|
||||||
> a caller assemble 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.
|
|
||||||
|
|
||||||
#### The playback-kind decision
|
|
||||||
|
|
||||||
`decide_playback_kind` is a free function and pure, so every branch is testable
|
|
||||||
from `PlaybackInfo` fixtures without a server. Order matters — the two
|
|
||||||
client-side overrides come first, because each describes a case where the
|
|
||||||
server's answer is right about the *file* and wrong about what this app will do
|
|
||||||
with it:
|
|
||||||
|
|
||||||
1. **Undecodable audio → `Transcode`.** 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.
|
|
||||||
A silent direct play is worse than a transcode.
|
|
||||||
2. **A pinned audio track → `Transcode`.** Not a defect in the server's answer, a
|
|
||||||
different question: the file has one default track and the viewer asked for
|
|
||||||
another.
|
|
||||||
3. Otherwise `supports_direct_play` → `DirectPlay`, else `supports_direct_stream`
|
|
||||||
→ `DirectStream`, else `Transcode`.
|
|
||||||
|
|
||||||
A direct **stream** is a remux — codecs copied, container repackaged. It is cheap
|
|
||||||
and is deliberately *not* counted as transcoding; conflating the two would report
|
|
||||||
a free passthrough as a server-side re-encode.
|
|
||||||
|
|
||||||
> **What this is worth, measured.** Against the development server (Jellyfin
|
|
||||||
> 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation
|
|
||||||
> per profile:
|
|
||||||
>
|
|
||||||
> | Profile | Direct play |
|
|
||||||
> |---|---|
|
|
||||||
> | Linux / WebKitGTK (`h264` only, 2ch) | 3/40 — **7%** |
|
|
||||||
> | Android / ExoPlayer (`h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch) | 34/40 — **85%** |
|
|
||||||
>
|
|
||||||
> The library is ~80% hevc (`hevc+eac3` alone is a third of it), which is why the
|
|
||||||
> two diverge so hard. **The payoff is overwhelmingly Android**, where 85% of
|
|
||||||
> plays previously burned 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, and is what `linux-native-video-spike.md` exists to
|
|
||||||
> remove. A reviewer should not expect this code to fix Linux on its own.
|
|
||||||
|
|
||||||
#### The quality ladder per source
|
|
||||||
|
|
||||||
`quality_options_for_source(source_bitrate)` returns every rung, each marked with
|
|
||||||
`exceeds_source`: true when that rung's ceiling is at or above what the source
|
|
||||||
itself carries, so selecting it produces the same bytes as `Original`. The
|
|
||||||
frontend draws the list and drops the redundant rungs; it does not decide which
|
|
||||||
they are.
|
|
||||||
|
|
||||||
- `Original` is never marked — it *is* the source.
|
|
||||||
- An unreported source bitrate (some containers have none; the sampled library
|
|
||||||
has `avi` files with no bitrate at all) marks **nothing** redundant, keeping
|
|
||||||
every rung offered. That is the safe direction: the viewer keeps every choice.
|
|
||||||
|
|
||||||
#### No adaptive ladder to preserve
|
|
||||||
|
|
||||||
**TRACES: UR-079 | DR-228 (Won't Do)**
|
|
||||||
|
|
||||||
Mid-playback re-negotiation on throughput was scoped and dropped on measurement.
|
|
||||||
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 that mpv would lose — the claim that there was is recorded in
|
|
||||||
`playback-backend-unification.md` and does not hold. "Adapt mid-stream" collapses
|
|
||||||
into "pick well at open", which is what the two levels of ceiling and the
|
|
||||||
per-source ladder already are.
|
|
||||||
|
|
||||||
Kept here because it is a measurement, not an opinion: a server that *does*
|
|
||||||
publish a ladder would change the answer, and the re-negotiation path below is
|
|
||||||
the hook that work would build on.
|
|
||||||
|
|
||||||
#### Re-negotiation
|
|
||||||
|
|
||||||
One mechanism, not two. `player_seek_video`, `player_switch_audio_track` and
|
|
||||||
`player_set_stream_quality` all return a tagged `strategy` saying who reloads —
|
|
||||||
the backend handles a native backend itself and hands the webview a
|
|
||||||
`StreamSelection` for `reloadSource`. Note the wire wart: tauri-specta keeps
|
|
||||||
these response fields snake_case (`seek_offset`), while the `strategy` tag itself
|
|
||||||
is camelCase.
|
|
||||||
|
|
||||||
The frontend names a variant and nothing else; the labels the picker shows are
|
The frontend names a variant and nothing else; the labels the picker shows are
|
||||||
served over IPC — from `available` on the selection, or
|
served over IPC by `player_get_streaming_qualities`.
|
||||||
`player_get_streaming_qualities` for the Settings list.
|
|
||||||
|
|
||||||
## Background workers
|
## Background workers
|
||||||
|
|
||||||
|
|||||||
@@ -802,45 +802,6 @@ by exactly the inset.
|
|||||||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
||||||
can safely be re-sent on resume.
|
can safely be re-sent on resume.
|
||||||
|
|
||||||
## Stream Transport
|
|
||||||
|
|
||||||
**Location**: `src/lib/player/streamTransport.ts`
|
|
||||||
**TRACES**: UR-079 | DR-224 | UT-213
|
|
||||||
|
|
||||||
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
|
|
||||||
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
|
|
||||||
`selection.transport`. `elementSrcFor` is its template companion: the element's
|
|
||||||
`src` is emptied only when hls.js is driving it.
|
|
||||||
|
|
||||||
The split is the point. **The transport is the stream's property and comes from
|
|
||||||
Rust; whether a given loader exists is the browser's, and is the only thing
|
|
||||||
decided here.**
|
|
||||||
|
|
||||||
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
|
|
||||||
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
|
|
||||||
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
|
|
||||||
> here by substring match was a domain fact reconstructed in the presentation
|
|
||||||
> layer, and it fails silently in both directions. The two tests 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 no `.m3u8` must.
|
|
||||||
>
|
|
||||||
> Logic lives in a plain `.ts` module rather than in the component for the usual
|
|
||||||
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
|
|
||||||
|
|
||||||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
|
||||||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
|
||||||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
|
||||||
drift apart. The background-audio handoff states the transport it is moving to —
|
|
||||||
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
|
||||||
inferred.
|
|
||||||
|
|
||||||
The quality picker is filled from `selection.available` (DR-226): rungs the
|
|
||||||
backend marked `exceedsSource` are not drawn, because they produce the same bytes
|
|
||||||
as `Original`. Nothing is optimistically assigned when the viewer picks a rung —
|
|
||||||
what the menu shows comes from the selection the backend hands back, since a
|
|
||||||
ceiling above the source bitrate *is* the source.
|
|
||||||
|
|
||||||
## Native Video Store
|
## Native Video Store
|
||||||
|
|
||||||
**Location**: `src/lib/stores/nativeVideo.ts`
|
**Location**: `src/lib/stores/nativeVideo.ts`
|
||||||
|
|||||||
@@ -132,55 +132,6 @@ sequenceDiagram
|
|||||||
Note over Store: UI updates reactively
|
Note over Store: UI updates reactively
|
||||||
```
|
```
|
||||||
|
|
||||||
## Video Stream Selection Flow
|
|
||||||
|
|
||||||
**TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227**
|
|
||||||
|
|
||||||
Before a video plays, Rust decides *what stream* — direct play, remux or
|
|
||||||
transcode, over which transport — and hands the player one self-describing
|
|
||||||
`StreamSelection`. The page no longer inspects the URL to work any of this out.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Page as player/[id]/+page.svelte
|
|
||||||
participant Repo as HybridRepository
|
|
||||||
participant Online as OnlineRepository
|
|
||||||
participant Server as Jellyfin
|
|
||||||
participant VP as VideoPlayer.svelte
|
|
||||||
|
|
||||||
Page->>Repo: playerLocalMediaPath(id)
|
|
||||||
alt a completed download exists
|
|
||||||
Page->>Repo: mediaLocalSelection(path)
|
|
||||||
Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
|
|
||||||
else stream from the server
|
|
||||||
Page->>Repo: getStreamSelection(id, mediaSourceId)
|
|
||||||
Repo->>Online: get_stream_selection()
|
|
||||||
Online->>Online: effective_streaming_quality()
|
|
||||||
Note over Online: per-playback override, else device default
|
|
||||||
Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
|
|
||||||
Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
|
|
||||||
Online->>Online: decide_playback_kind()
|
|
||||||
alt Transcode
|
|
||||||
Online->>Online: adopt/stop prior play session,<br/>build HLS URL
|
|
||||||
Note over Online: Transport::Hls
|
|
||||||
else DirectPlay / DirectStream
|
|
||||||
Online->>Online: /Videos/{id}/stream?static=true
|
|
||||||
Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
|
|
||||||
end
|
|
||||||
Online->>Online: quality_options_for_source(bitrate)
|
|
||||||
Online-->>Page: StreamSelection
|
|
||||||
end
|
|
||||||
Page->>VP: selection
|
|
||||||
VP->>VP: videoLoaderFor(selection, caps)
|
|
||||||
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
|
|
||||||
```
|
|
||||||
|
|
||||||
The selection travels with the stream from then on. A reload — a quality change,
|
|
||||||
an audio-track switch, a transcoded seek — returns a *new* selection through the
|
|
||||||
same tagged `strategy` response, so transport and URL can never disagree; and the
|
|
||||||
queue item carries the transport so `player_seek_video` picks its seek strategy
|
|
||||||
from the backend's decision rather than from the URL string.
|
|
||||||
|
|
||||||
## Playback Mode Transfer Flow
|
## Playback Mode Transfer Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
Vendored
-23
@@ -80,29 +80,6 @@ docker run --rm gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09 \
|
|||||||
toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push,
|
toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push,
|
||||||
then merge.
|
then merge.
|
||||||
|
|
||||||
## Tauri plugin versions are pinned in pairs
|
|
||||||
|
|
||||||
Every Tauri plugin exists twice: a Rust crate in `src-tauri/Cargo.toml` and an
|
|
||||||
npm package in `package.json`. **The Tauri CLI refuses to build when the two are
|
|
||||||
on different minor versions** — not a warning, a hard stop before compilation.
|
|
||||||
|
|
||||||
Both sides are therefore pinned *exactly* (`"2.8.0"`, not `"^2.8.0"`). A caret
|
|
||||||
range is what let them drift apart in the first place: `bun add` took the latest
|
|
||||||
npm package while cargo held an older crate, and nothing noticed until a release
|
|
||||||
build refused to start.
|
|
||||||
|
|
||||||
Nothing in `build-and-test.yml` runs `tauri build` — that happens only on a tag —
|
|
||||||
so this class of breakage used to be invisible until release day. The
|
|
||||||
`Check Tauri plugin versions match` step runs `tauri info`, which performs the
|
|
||||||
same comparison without building.
|
|
||||||
|
|
||||||
To upgrade a plugin, move **both** sides together and re-run that step. Expect
|
|
||||||
the Rust side to be the constraint: a newer plugin crate may pull a large
|
|
||||||
transitive upgrade (bumping `tauri-plugin-log` to 2.9.0 also moved `wry`,
|
|
||||||
`wasm-bindgen`, `web-sys` and `webkit2gtk`), which touches the webview and
|
|
||||||
therefore video playback. That is a change to make deliberately, with a full
|
|
||||||
build and a playback check — not one to slip into a release.
|
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
|
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ For a narrative overview of the system design, see
|
|||||||
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
|
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
|
||||||
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
|
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
|
||||||
| 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-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-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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -411,17 +410,6 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
||||||
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
||||||
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
||||||
| DR-219 | Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the `## <version>` section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. `release:notes` is printed into the job log as a drafting aid but is deliberately *not* published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (`chore(format)`, `chore(deps)`, `style`) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller set | Tooling | - | Done |
|
|
||||||
| DR-220 | A release ships only its own artifacts. `src-tauri/target/*/release/bundle/` is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's `bundle/**/*-setup.exe` glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. `scripts/check-release-artifacts.sh` is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tag | Tooling | - | Done |
|
|
||||||
| DR-221 | The release path is exercised before a tag exists. Nothing in `build-and-test.yml` runs `tauri build` — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (`tauri-plugin-log 2.8.0` against `@tauri-apps/plugin-log 2.9.0`) while `cargo check`, clippy, the tests and `svelte-check` all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs `tauri info` to compare them without building. The AppImage target had never once been built: linuxdeploy carries a `strip` too old to parse the `.relr.dyn` section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with `-z pack-relative-relocs` by default, so the builder image fails the same way a modern Arch host does. `NO_STRIP=true` is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not build | Tooling | - | Done |
|
|
||||||
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
|
|
||||||
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done |
|
|
||||||
| DR-224 | `StreamSelection` replaces the bare URL returned for playback: URL, `Transport` (hls / progressive / localFile), `PlaybackKind` (directPlay / directStream / transcode), the negotiated `Rendition`, the ladder this source can offer, and a `needs_transcoding` flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a discriminant rather than comparing text. The field that mattered most is `transport`: `VideoPlayer.svelte` chose its loader with `url.includes(".m3u8")` in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (`media_local_selection`, `LiveStreamInfo.transport`) rather than having the page assemble one, so there is no second place where a transport is decided | Playback | UR-079 | Done |
|
|
||||||
| DR-225 | The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. `effective_streaming_quality()` is the single resolution point; every URL builder and the `PlaybackInfo` negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the cap | Playback | UR-074, UR-079 | Done |
|
|
||||||
| DR-226 | The quality picker is filled from what *this* media source can offer, not from the fixed eight-rung enum. Rust marks each rung `exceeds_source` when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as `Original`, so offering it is another way to spell one choice — and the frontend simply does not draw those. `Original` is never marked (it *is* the source) and a source whose bitrate the server does not report (the sampled library has `avi` files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once `PlaybackKind` existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it does | UI | UR-070, UR-079 | Done |
|
|
||||||
| DR-227 | Direct play and direct stream are negotiated rather than assumed away. `get_video_stream_url` always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from `PlaybackInfo` under the device profile and the ceiling in force, with two client-side overrides applied 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 source file does not default to. Measured against the development server over a 400-item sample: **85% direct play on the Android profile, 7% on the Linux one** — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what `linux-native-video-spike.md` exists to change. A direct *stream* is a remux and is deliberately not counted as transcoding | Playback | UR-079 | Done |
|
|
||||||
| DR-228 | Mid-playback re-negotiation on throughput was scoped and **dropped on measurement**. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one `EXT-X-STREAM-INF`, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build on | Playback | UR-079 | Won't Do |
|
|
||||||
| DR-229 | Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated `transport`, so `player_seek_video` picks its seek strategy from the backend's own decision instead of the last `stream_url.contains(".m3u8")` in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) 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 webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferred | Playback | UR-003, UR-004, UR-079 | Done |
|
|
||||||
| 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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -509,7 +497,6 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-076 | - | DR-209 |
|
| UR-076 | - | DR-209 |
|
||||||
| UR-077 | - | DR-217 |
|
| UR-077 | - | DR-217 |
|
||||||
| UR-078 | - | DR-218 |
|
| UR-078 | - | DR-218 |
|
||||||
| UR-079 | - | DR-224, DR-225, DR-226, DR-227, DR-228, DR-229 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -722,10 +709,6 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||||
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
||||||
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
||||||
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
|
|
||||||
| UT-211 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
|
|
||||||
| UT-212 | 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-213 | 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-224 implementation before the fix landed | DR-224 | Done |
|
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ know how something *works*, read
|
|||||||
|
|
||||||
**Next free requirement ids** (always re-check
|
**Next free requirement ids** (always re-check
|
||||||
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
||||||
**IR-033**, **DR-229**. Three specs below suggested ids that have since been
|
**IR-033**, **DR-219**. Three specs below suggested ids that have since been
|
||||||
taken by other work; each carries a ⚠️ note at the top.
|
taken by other work; each carries a ⚠️ note at the top.
|
||||||
|
|
||||||
## Partially implemented
|
## Partially implemented
|
||||||
@@ -37,7 +37,7 @@ taken by other work; each carries a ⚠️ note at the top.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
||||||
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
||||||
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
|
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-121/122/124/125 — the player quality selector and the read-through capture |
|
||||||
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
||||||
|
|
||||||
## Not started
|
## Not started
|
||||||
@@ -47,7 +47,7 @@ taken by other work; each carries a ⚠️ note at the top.
|
|||||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
| [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. |
|
| [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. |
|
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||||
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. The adaptive-bitrate question it was waiting on is **answered**: the server publishes one `EXT-X-STREAM-INF`, so there is no ladder for mpv to lose (DR-228). `StreamSelection` (DR-224) is the contract to consume. |
|
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. Needs an implementation spec that answers adaptive bitrate. |
|
||||||
|
|
||||||
## Design authority
|
## Design authority
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Spec: Linux native video — bounded compositing spike
|
# Spec: Linux native video — bounded compositing spike
|
||||||
|
|
||||||
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
|
**Status:** **Run 2026-08-21 — G1-G6 green except the Tauri-tree half of G1.**
|
||||||
The compositing claim it set out to test is falsified on Linux. See "Result".
|
The compositing claim it set out to test is falsified on Linux. See "Result".
|
||||||
This file stays open until the implementation spec exists; ABR is unresolved.
|
This file stays open until the implementation spec exists; ABR is unresolved.
|
||||||
**Requirements:** none allocated. This spike produces a decision record, not
|
**Requirements:** none allocated. This spike produces a decision record, not
|
||||||
@@ -186,7 +186,7 @@ mpv's render API with an update callback, frame-gated repaints and
|
|||||||
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
|
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
|
||||||
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
|
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
|
||||||
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
|
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
|
||||||
| G5 resize / drag / fullscreen | 🟡 **green on appearance, suspect underneath** | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open". |
|
| G5 resize / drag / fullscreen | ✅ green | No flicker, no gap, no misalignment. Fullscreen juddered until frame pacing was done properly — see trap 3; it is smooth with `report_swap` in place. |
|
||||||
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
|
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
|
||||||
|
|
||||||
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||||
@@ -244,12 +244,9 @@ which is the least efficient hardware path. An implementation should evaluate
|
|||||||
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
|
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
|
||||||
accepting `auto`.
|
accepting `auto`.
|
||||||
|
|
||||||
`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support.
|
`hwdec=auto-safe`, the default this spike started with, probes Vulkan video
|
||||||
It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and
|
decode, which this GPU does not support. It failed per-frame and logged
|
||||||
then settles on `nvdec-copy` — the same place `auto` lands. A first reading of
|
`no frame!` on every frame. Do not ship `auto-safe` here without checking that.
|
||||||
these logs mistook the start-up pair for a per-frame flood; **it is not**. Every
|
|
||||||
run, clean or crashed, contains exactly two. `auto-safe` is not implicated in
|
|
||||||
anything.
|
|
||||||
|
|
||||||
### What is still open
|
### What is still open
|
||||||
|
|
||||||
@@ -258,72 +255,15 @@ anything.
|
|||||||
Tauri's existing webview into an overlay. Low risk — the same widgets, one
|
Tauri's existing webview into an overlay. Low risk — the same widgets, one
|
||||||
extra reparent — but unproven, and it is the only place Tauri-specific
|
extra reparent — but unproven, and it is the only place Tauri-specific
|
||||||
behaviour could still bite.
|
behaviour could still bite.
|
||||||
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress
|
- **ABR — unchanged and still the blocker.** Nothing here addresses finding 3.
|
||||||
streaming quality because "the webview path already has real ABR via hls.js".
|
What has changed is that the direct-play/transcode split is now worth designing
|
||||||
Three pieces of evidence in this repo suggest that is **not true of the URLs we
|
rather than moot.
|
||||||
actually build**:
|
- **One unexplained SIGSEGV.** A ~180s run crashed in a *decoder* thread
|
||||||
|
(libavcodec -> `av_log` -> libmpv's log handler -> libc), not in the GL or
|
||||||
1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
|
compositing path, while `hwdec=auto-safe` was failing its Vulkan probe on every
|
||||||
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
|
frame. It did **not** reproduce across five subsequent runs (2x45s, 3x20s) on
|
||||||
Jellyfin transcodes to what it is asked for; it does not build a ladder.
|
`no`, `auto` and `vaapi`. Cause unconfirmed; recorded rather than dismissed.
|
||||||
2. The frontend contains **no level-handling code at all** — no `hls.levels`,
|
Anyone implementing this should run a multi-hour soak before trusting it.
|
||||||
no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in
|
|
||||||
`VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is
|
|
||||||
serving as an HLS *demuxer* (WebKitGTK cannot play HLS natively), not as an
|
|
||||||
adaptation engine.
|
|
||||||
3. That function's own comment describes a quality switch as **rebuilding the
|
|
||||||
URL** — "every path that re-opens a stream (quality switch, transcoded seek,
|
|
||||||
audio-track switch)". Manual selection by stream re-open is what you build
|
|
||||||
when there is no adaptation, and mpv can do the same thing.
|
|
||||||
|
|
||||||
**The decisive test has not been run** and needs a live server plus an API key:
|
|
||||||
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there
|
|
||||||
is no ABR to lose and this blocker disappears. More than one means finding 3
|
|
||||||
stands and the work below applies.
|
|
||||||
|
|
||||||
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there
|
|
||||||
are three designs in increasing cost: pick the variant at open; re-open at a
|
|
||||||
new bitrate on sustained throughput drops (this is the quality-switch path the
|
|
||||||
app already has, so it is nearly free); or run a local proxy serving mpv a
|
|
||||||
synthesized single-variant playlist while swapping renditions underneath. The
|
|
||||||
middle option is almost certainly sufficient.
|
|
||||||
|
|
||||||
Either way the **direct-play path still does not exist** — every video play
|
|
||||||
currently goes through the HLS transcode endpoint. Building it is the real
|
|
||||||
project; the compositing work proven above is the smaller half.
|
|
||||||
- 🔴 **One unexplained SIGSEGV.** A ~180s
|
|
||||||
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
|
|
||||||
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
|
|
||||||
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
|
|
||||||
compositing seam.
|
|
||||||
|
|
||||||
Three hypotheses were tested and **none reproduced it**:
|
|
||||||
|
|
||||||
| Hypothesis | Test | Result |
|
|
||||||
|---|---|---|
|
|
||||||
| `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead. |
|
|
||||||
| Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped. |
|
|
||||||
| Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped. |
|
|
||||||
|
|
||||||
**The crash is therefore unexplained.** It was observed exactly once, in the
|
|
||||||
only session a human interacted with, and did not recur in ~13 minutes of
|
|
||||||
targeted stress across the three most plausible causes. It is recorded here
|
|
||||||
rather than dismissed precisely because nothing explains it: an intermittent
|
|
||||||
fault that nobody can reproduce is worse to inherit than a deterministic one,
|
|
||||||
not better.
|
|
||||||
|
|
||||||
The underlying concern stands regardless of which test eventually reproduces
|
|
||||||
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
|
|
||||||
and this spike never calls `mpv_render_context_free` and never tears down on
|
|
||||||
`unrealize` — it has no defence against the GL context being recreated beneath
|
|
||||||
the render context. That is DR-184 on Android restated: a surface outliving its
|
|
||||||
player. An implementation must bind the two lifetimes together whether or not
|
|
||||||
this particular crash is ever explained.
|
|
||||||
|
|
||||||
**Therefore G5 is recorded green on appearance only**, and this crash is the
|
|
||||||
single largest piece of unfinished business in the spike. Do not read the green
|
|
||||||
gates above as "safe to build on" until it is explained or a long soak clears
|
|
||||||
it.
|
|
||||||
- Long-run stability, seeking, track switching, HDR, and multi-window were not
|
- Long-run stability, seeking, track switching, HDR, and multi-window were not
|
||||||
exercised at all.
|
exercised at all.
|
||||||
|
|
||||||
|
|||||||
@@ -111,17 +111,6 @@ The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
|||||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
**downgrade** on every platform — no graceful degradation on weak networks, and
|
||||||
quality changes requiring teardown and reload.
|
quality changes requiring teardown and reload.
|
||||||
|
|
||||||
> **Premise in doubt (2026-08-21).** "The webview path already has real ABR"
|
|
||||||
> was not verified against the URLs this app actually builds.
|
|
||||||
> `get_video_stream_url` requests a *single* rendition (one `VideoBitrate`, one
|
|
||||||
> `MaxHeight`), the frontend has **no** level-handling code (`hls.levels`,
|
|
||||||
> `LEVEL_SWITCH`, `currentLevel` appear nowhere), and this repo implements a
|
|
||||||
> quality switch by *re-opening the stream* — all of which point to a
|
|
||||||
> single-variant playlist, i.e. no ABR to lose. The decisive test is counting
|
|
||||||
> `#EXT-X-STREAM-INF` lines in a real `master.m3u8`; it needs a live server and
|
|
||||||
> has not been run. See
|
|
||||||
> [linux-native-video-spike.md](linux-native-video-spike.md).
|
|
||||||
|
|
||||||
### 4. Crossfade is architecturally blocked on mpv
|
### 4. Crossfade is architecturally blocked on mpv
|
||||||
|
|
||||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
||||||
|
|||||||
@@ -4,20 +4,15 @@
|
|||||||
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
|
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
|
||||||
eviction only reclaims the temporary tier), local playback of downloaded media
|
eviction only reclaims the temporary tier), local playback of downloaded media
|
||||||
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
|
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
|
||||||
DR-123 is in progress. Still open: the read-through capture itself — DR-122,
|
DR-123 is in progress. Still open: the **player quality selector** and the
|
||||||
DR-124, DR-125.
|
read-through capture itself — DR-121, DR-122, DR-124, DR-125. The separate
|
||||||
|
settings-level bitrate cap (DR-162, shipped —
|
||||||
**DR-121 has shipped and left this spec.** The player quality selector, the
|
[01-rust-backend.md](../architecture/01-rust-backend.md#streaming-quality-ladder))
|
||||||
per-playback bitrate ceiling, and the backend-owned stream decision it needed
|
covers a *settings-level*
|
||||||
were built as *backend-owned stream selection* (DR-224 … DR-227) and are
|
ceiling (DR-162), which serves part of UR-070 but is not the per-playback
|
||||||
described in
|
selector specified here.
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection) and
|
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
|
||||||
[03-data-flow.md](../architecture/03-data-flow.md#video-stream-selection-flow).
|
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
|
||||||
The settings-level ceiling (DR-162) is the same section. What remains here is the
|
|
||||||
*capture* half only — this spec no longer specifies anything about choosing a
|
|
||||||
bitrate.
|
|
||||||
|
|
||||||
**Requirements:** UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032
|
|
||||||
**Related:** the locally-indexed search and downloaded-browse work, both
|
**Related:** the locally-indexed search and downloaded-browse work, both
|
||||||
shipped — see
|
shipped — see
|
||||||
[03-data-flow.md](../architecture/03-data-flow.md) and
|
[03-data-flow.md](../architecture/03-data-flow.md) and
|
||||||
@@ -75,16 +70,24 @@ frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
|||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### DR-121 — moved out (shipped)
|
### DR-121 — Bitrate selection in the player
|
||||||
|
|
||||||
Bitrate selection in the player shipped as DR-224 … DR-227; see
|
The player exposes the qualities Rust reports for the current item. Changing it
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection).
|
re-negotiates the stream URL at the new quality and resumes at the current
|
||||||
|
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
|
||||||
|
expected and acceptable, unlike the involuntary swap the earlier design would
|
||||||
|
have needed.
|
||||||
|
|
||||||
The one constraint here that the capture work still has to respect: a quality
|
Constraints that must not be broken:
|
||||||
change re-negotiates **within HLS**. Returning a progressive `stream.mp4` for a
|
|
||||||
transcode means playback never starts, because the server encodes the whole file
|
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
|
||||||
before serving a byte (DR-140). That is why DR-122 below abandons a capture on a
|
records that returning `stream.mp4` means transcoded playback never starts.
|
||||||
quality change rather than trying to splice one.
|
A quality change re-negotiates *within* HLS.
|
||||||
|
- The quality→transcode-parameter mapping already exists in
|
||||||
|
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
|
||||||
|
Playback must call into the same mapping. Two copies of that table will drift.
|
||||||
|
- Track selection (audio/subtitle) already survives a stream re-negotiation
|
||||||
|
elsewhere in the player; a quality change must preserve it too.
|
||||||
|
|
||||||
### DR-122 — The playback path is ephemeral
|
### DR-122 — The playback path is ephemeral
|
||||||
|
|
||||||
@@ -209,6 +212,7 @@ codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
|||||||
|
|
||||||
| Piece | Tag |
|
| Piece | Tag |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
|
||||||
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
||||||
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
||||||
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
||||||
|
|||||||
+5807
-7062
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.10.0",
|
"version": "0.9.1",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
"check:links": "bash scripts/check-doc-links.sh",
|
"check:links": "bash scripts/check-doc-links.sh",
|
||||||
"check:tooling": "bash scripts/check-tooling.sh",
|
|
||||||
"hooks:install": "./scripts/install-hooks.sh",
|
"hooks:install": "./scripts/install-hooks.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
@@ -56,12 +55,12 @@
|
|||||||
"release:notes": "bun run scripts/release-notes.ts"
|
"release:notes": "bun run scripts/release-notes.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-log": "2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"svelte-dnd-action": "^0.9.69"
|
"svelte-dnd-action": "^0.9.69"
|
||||||
},
|
},
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||||
|
|
||||||
pkgname=jellytau
|
pkgname=jellytau
|
||||||
pkgver=0.10.0
|
pkgver=0.9.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A cross-platform Jellyfin client"
|
pkgdesc="A cross-platform Jellyfin client"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -82,17 +82,7 @@ fi
|
|||||||
if [ "$CLEAN" = "1" ]; then
|
if [ "$CLEAN" = "1" ]; then
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
echo "🧹 Clearing build caches (clean build)..."
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
# `bun install`, NOT `npm install`. This is a bun project (see packageManager
|
npm install > /dev/null 2>&1
|
||||||
# in package.json) and bun.lock is the lockfile that is committed; npm
|
|
||||||
# ignores it, re-resolves the tree from package.json alone, and writes a
|
|
||||||
# package-lock.json that .gitignore then hides.
|
|
||||||
#
|
|
||||||
# That is not cosmetic. The Tauri CLI refuses to build when a plugin's Rust
|
|
||||||
# crate and npm package differ by minor version, so the JS side is pinned
|
|
||||||
# exactly to match Cargo.lock; a re-resolve is precisely how those halves
|
|
||||||
# drift apart again. A clean build must not be able to change what gets
|
|
||||||
# installed.
|
|
||||||
bun install > /dev/null 2>&1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
@@ -104,28 +94,22 @@ echo "🎨 Building frontend..."
|
|||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
# `--apk` is a boolean flag, NOT `--apk true`.
|
|
||||||
#
|
|
||||||
# tauri-cli took a value here until 2.10; from 2.11 it is a plain flag and the
|
|
||||||
# stray `true` is parsed as a positional argument, failing with
|
|
||||||
# "error: unexpected argument 'true' found" before the build starts. Found by
|
|
||||||
# deploying to a device after the Tauri 2.9.5 -> 2.11.5 upgrade.
|
|
||||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
# A release build in the debug slot: R8 still runs, but the applicationId is
|
# A release build in the debug slot: R8 still runs, but the applicationId is
|
||||||
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
||||||
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
||||||
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
||||||
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
||||||
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
# Configure release signing from .env (single source of truth). Must run
|
# Configure release signing from .env (single source of truth). Must run
|
||||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
else
|
else
|
||||||
echo "📦 Building debug APK..."
|
echo "📦 Building debug APK..."
|
||||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -26,25 +26,7 @@ bun run build
|
|||||||
|
|
||||||
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
||||||
# exactly which Linux formats are produced (never NSIS here).
|
# exactly which Linux formats are produced (never NSIS here).
|
||||||
# TRACES: | DR-221
|
bun run tauri build --bundles "$BUNDLES"
|
||||||
#
|
|
||||||
# 🔴 NO_STRIP=true is required for the AppImage bundle.
|
|
||||||
#
|
|
||||||
# linuxdeploy (which Tauri downloads and runs to build the AppImage) carries its
|
|
||||||
# own `strip`, and that copy is too old to parse the `.relr.dyn` section modern
|
|
||||||
# toolchains emit for RELR relocations. It fails on essentially every bundled
|
|
||||||
# library:
|
|
||||||
#
|
|
||||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
|
||||||
# failed to bundle project `failed to run linuxdeploy-x86_64.AppImage`
|
|
||||||
#
|
|
||||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI builder
|
|
||||||
# image hits this exactly as a modern Arch host does. Skipping the strip step is
|
|
||||||
# linuxdeploy's own documented escape hatch; the cost is an unstripped, larger
|
|
||||||
# AppImage (~153 MB for a build that bundles libmpv and its ffmpeg stack).
|
|
||||||
#
|
|
||||||
# Remove this only after confirming a linuxdeploy release that understands RELR.
|
|
||||||
NO_STRIP=true bun run tauri build --bundles "$BUNDLES"
|
|
||||||
|
|
||||||
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -46,28 +46,6 @@ bun run build
|
|||||||
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
||||||
# that CLI validation — the bundler then picks nsis once it knows the target is
|
# that CLI validation — the bundler then picks nsis once it knows the target is
|
||||||
# Windows.
|
# Windows.
|
||||||
# TRACES: | DR-221
|
|
||||||
#
|
|
||||||
# 🔴 Clear the bundle output before building.
|
|
||||||
#
|
|
||||||
# The bundle directory is not versioned and is never cleaned by cargo, and the
|
|
||||||
# CI runner reuses src-tauri/target between builds. The copy step below globs
|
|
||||||
# `bundle/**/*-setup.exe`, so every stale installer left there was picked up and
|
|
||||||
# attached to the release: v0.8.2 shipped sixteen Windows installers, thirteen
|
|
||||||
# of them from earlier versions, and v0.5.0 offered users a download list going
|
|
||||||
# back to 0.1.0. Every release from v0.1.0 to v0.8.2 did this. It stopped only
|
|
||||||
# because an unrelated change wiped the runner's target dir, so it is dormant
|
|
||||||
# rather than fixed.
|
|
||||||
#
|
|
||||||
# Filtering the copy by version would hide it; removing the directory means a
|
|
||||||
# stale file cannot exist to be copied. scripts/check-release-artifacts.sh is
|
|
||||||
# the backstop if some other path reintroduces one.
|
|
||||||
BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
|
|
||||||
if [[ -d "$BUNDLE_DIR" ]]; then
|
|
||||||
echo "🧹 Clearing previous bundle output at $BUNDLE_DIR"
|
|
||||||
rm -rf "$BUNDLE_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Refuse to publish a release whose artifacts are not all from this release.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-220
|
|
||||||
#
|
|
||||||
# ./scripts/check-release-artifacts.sh <version> <dir> [<dir>...]
|
|
||||||
#
|
|
||||||
# e.g.
|
|
||||||
# ./scripts/check-release-artifacts.sh v0.9.2 artifacts/linux artifacts/windows
|
|
||||||
#
|
|
||||||
# ## The defect this exists for
|
|
||||||
#
|
|
||||||
# Every JellyTau release from v0.1.0 to v0.8.2 shipped every Windows installer
|
|
||||||
# ever built. `src-tauri/target/*/release/bundle/` is not versioned, cargo never
|
|
||||||
# cleans it, and the CI runner reuses the target directory between builds — so
|
|
||||||
# the copy step's `bundle/**/*-setup.exe` glob collected the whole history. By
|
|
||||||
# v0.8.2 that was sixteen installers, thirteen of them stale. v0.5.0 offered
|
|
||||||
# users a download list going back to 0.1.0.
|
|
||||||
#
|
|
||||||
# Nobody noticed for eight months. There was nothing to notice with: the upload
|
|
||||||
# loop reported success, the assets were real files, and the release page looked
|
|
||||||
# busy rather than wrong.
|
|
||||||
#
|
|
||||||
# The builds now clear the bundle directory first, which removes the cause. This
|
|
||||||
# is the backstop for the next thing that reintroduces a stale file by a route
|
|
||||||
# nobody predicted — a cached directory, a restored artifact, a hand-copied fix.
|
|
||||||
#
|
|
||||||
# ## What it checks
|
|
||||||
#
|
|
||||||
# Every file whose name embeds a semantic version must embed *this* version.
|
|
||||||
# Files with no version in the name (jellytau-release.apk, jellytau.exe,
|
|
||||||
# SHA256SUMS, latest.json) are accepted: they are produced fresh each build and
|
|
||||||
# have no version to disagree with.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
echo "usage: $0 <version> <dir> [<dir>...]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION_RAW="$1"
|
|
||||||
shift
|
|
||||||
# Accept the tag form (v0.9.2) or the bare form (0.9.2).
|
|
||||||
VERSION="${VERSION_RAW#v}"
|
|
||||||
|
|
||||||
echo "🔎 Checking release artifacts are all version ${VERSION}…"
|
|
||||||
|
|
||||||
FOUND=0
|
|
||||||
STALE=0
|
|
||||||
UNVERSIONED=0
|
|
||||||
|
|
||||||
for dir in "$@"; do
|
|
||||||
if [ ! -d "$dir" ]; then
|
|
||||||
echo " (no $dir — skipping)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -print0/read -d '' so a filename with a space cannot split into two.
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
name="$(basename "$file")"
|
|
||||||
FOUND=$((FOUND + 1))
|
|
||||||
|
|
||||||
# First x.y.z in the filename, if any.
|
|
||||||
embedded="$(printf '%s' "$name" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
|
||||||
|
|
||||||
if [ -z "$embedded" ]; then
|
|
||||||
UNVERSIONED=$((UNVERSIONED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$embedded" != "$VERSION" ]; then
|
|
||||||
echo " ❌ $name carries version $embedded"
|
|
||||||
STALE=$((STALE + 1))
|
|
||||||
fi
|
|
||||||
done < <(find "$dir" -type f -print0)
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " $FOUND file(s) checked; $UNVERSIONED carry no version in the name."
|
|
||||||
|
|
||||||
if [ "$FOUND" -eq 0 ]; then
|
|
||||||
echo "❌ No artifacts found at all. A release with no files is a failed build," >&2
|
|
||||||
echo " not an empty one." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$STALE" -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ $STALE artifact(s) belong to a different version than ${VERSION}." >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo " This is how every release from v0.1.0 to v0.8.2 came to ship its" >&2
|
|
||||||
echo " predecessors' Windows installers: src-tauri/target/*/release/bundle/" >&2
|
|
||||||
echo " is never cleaned and the runner reuses it, so a glob picks up" >&2
|
|
||||||
echo " whatever was left behind." >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo " The builds clear that directory first, so seeing this means a stale" >&2
|
|
||||||
echo " file arrived by some other route. Find it before publishing — do not" >&2
|
|
||||||
echo " delete the file and re-run." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Every versioned artifact is ${VERSION}."
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Refuse tooling that contradicts what this project actually uses.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-222
|
|
||||||
#
|
|
||||||
# ./scripts/check-tooling.sh
|
|
||||||
#
|
|
||||||
# ## Why
|
|
||||||
#
|
|
||||||
# This is a bun project: `packageManager` in package.json says so, bun.lock is
|
|
||||||
# the committed lockfile, and .gitignore hides the other package managers'
|
|
||||||
# lockfiles precisely so they cannot be committed by accident.
|
|
||||||
#
|
|
||||||
# scripts/build-android.sh nonetheless ran `npm install` on its clean-build
|
|
||||||
# path. npm ignores bun.lock, re-resolves the whole tree from package.json, and
|
|
||||||
# writes a package-lock.json that .gitignore then hides from view.
|
|
||||||
#
|
|
||||||
# That is not a style preference. The Tauri CLI refuses to build when a plugin's
|
|
||||||
# Rust crate and npm package differ by minor version, so the JS side is pinned
|
|
||||||
# exactly against Cargo.lock -- and a silent re-resolve is exactly how those
|
|
||||||
# halves drift apart again. The drift already cost one release build.
|
|
||||||
#
|
|
||||||
# It survived because the clean-build path runs rarely. That is the shape of
|
|
||||||
# nearly every defect found while preparing v0.10.0: the code that runs on every
|
|
||||||
# commit was fine, and the code that runs on a release, a clean build or a tag
|
|
||||||
# had no guard at all.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$REPO_ROOT" || exit 1
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
echo "🔎 Checking build tooling is consistent with packageManager…"
|
|
||||||
|
|
||||||
# Only the *invocations* matter. A comment explaining why npm is wrong, or a
|
|
||||||
# .gitignore entry naming package-lock.json, is not a violation -- so match a
|
|
||||||
# command at the start of a line or after a shell separator.
|
|
||||||
PATTERN='(^|[;&|(]|&&|\|\||\bthen |\bdo |[[:space:]]{4,})(npm|yarn|pnpm)[[:space:]]+(install|ci|add|run|exec)\b'
|
|
||||||
|
|
||||||
MATCHES="$(grep -rInE "$PATTERN" \
|
|
||||||
--include='*.sh' --include='*.yml' --include='*.yaml' \
|
|
||||||
scripts/ .gitea/ 2>/dev/null | grep -v '^\s*#' || true)"
|
|
||||||
|
|
||||||
if [ -n "$MATCHES" ]; then
|
|
||||||
echo "❌ A non-bun package manager is invoked:"
|
|
||||||
echo "$MATCHES" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
echo " This project uses bun (packageManager in package.json, bun.lock"
|
|
||||||
echo " committed). npm/yarn/pnpm ignore that lockfile and re-resolve the"
|
|
||||||
echo " dependency tree, which is how the Tauri plugin crate/package"
|
|
||||||
echo " versions drifted apart and broke a release build."
|
|
||||||
echo ""
|
|
||||||
echo " Use: bun install / bun run / bunx"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A lockfile from another manager should never exist here; .gitignore hides
|
|
||||||
# them, so one can sit in a working tree unnoticed and change what installs.
|
|
||||||
for stray in package-lock.json yarn.lock pnpm-lock.yaml; do
|
|
||||||
if [ -f "$stray" ]; then
|
|
||||||
echo "❌ $stray exists. Another package manager has run here."
|
|
||||||
echo " Delete it and run: bun install"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
|
||||||
echo "✅ Only bun is used, and no foreign lockfile is present."
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$FAILED"
|
|
||||||
@@ -49,11 +49,6 @@ describe("isTracedSourceFile", () => {
|
|||||||
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
|
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
|
||||||
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
|
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
|
||||||
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
|
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
|
||||||
// Shell tooling is listed individually, not globbed: most scripts/*.sh
|
|
||||||
// implement nothing, and adding one should be a decision.
|
|
||||||
expect(isTracedSourceFile("scripts/check-release-artifacts.sh")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("scripts/build-desktop-linux.sh")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("scripts/logcat.sh")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
|
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
|
||||||
|
|||||||
@@ -95,14 +95,6 @@ const TOOLING_FILES = new Set([
|
|||||||
"scripts/hooks/pre-commit",
|
"scripts/hooks/pre-commit",
|
||||||
"src-tauri/deny.toml",
|
"src-tauri/deny.toml",
|
||||||
"src-tauri/rust-toolchain.toml",
|
"src-tauri/rust-toolchain.toml",
|
||||||
// Shell tooling that implements a requirement. Named individually rather than
|
|
||||||
// globbing scripts/*.sh: most of these scripts implement nothing, and the
|
|
||||||
// point of the list is that adding a file is a decision.
|
|
||||||
"scripts/install-hooks.sh",
|
|
||||||
"scripts/check-release-artifacts.sh",
|
|
||||||
"scripts/build-desktop-linux.sh",
|
|
||||||
"scripts/build-windows-cross.sh",
|
|
||||||
"scripts/restore-ownership.sh",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Directory names that never contain hand-written traced source. */
|
/** Directory names that never contain hand-written traced source. */
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for release-note derivation.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-219 | UT-210
|
|
||||||
*
|
|
||||||
* The bug these were written against: `bun run release:notes v0.9.1..HEAD`
|
|
||||||
* listed *every user requirement in the project* as a feature of the release.
|
|
||||||
* The range contained a repo-wide `prettier --write` sweep, so `git diff
|
|
||||||
* --name-only` reported 199 files, their TRACES comments resolved to nearly the
|
|
||||||
* whole matrix, and the result claimed one release had added the entire
|
|
||||||
* application.
|
|
||||||
*
|
|
||||||
* That mattered more than it looked: build-release.yml now generates the
|
|
||||||
* published release body from this script, so the noise would have shipped.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { isCosmeticCommit } from "./release-notes";
|
|
||||||
|
|
||||||
describe("isCosmeticCommit", () => {
|
|
||||||
it("treats a formatting sweep as cosmetic", () => {
|
|
||||||
// The actual commit that triggered this.
|
|
||||||
expect(isCosmeticCommit("chore(format): run prettier over src/ and scripts/")).toBe(true);
|
|
||||||
expect(isCosmeticCommit("style: reindent the player module")).toBe(true);
|
|
||||||
expect(isCosmeticCommit("style(player): reindent")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a lockfile-only dependency bump as cosmetic", () => {
|
|
||||||
// Touches package.json/bun.lock, which carry no TRACES, but a `chore(deps)`
|
|
||||||
// that also edits source would still be caught by that source file.
|
|
||||||
expect(isCosmeticCommit("chore(deps): bump vitest to 4.1.11")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT treat ordinary work as cosmetic", () => {
|
|
||||||
expect(isCosmeticCommit("fix(player): restart the hero banner timer")).toBe(false);
|
|
||||||
expect(isCosmeticCommit("feat(updater): in-app update on desktop")).toBe(false);
|
|
||||||
expect(isCosmeticCommit("ci: make the frontend gates real")).toBe(false);
|
|
||||||
expect(isCosmeticCommit("docs: add SECURITY.md")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mistake a chore that is not formatting for a formatting one", () => {
|
|
||||||
// `chore(release)` bumps versions and must still be attributable; a bare
|
|
||||||
// `chore:` could be anything, so it is NOT skipped by default.
|
|
||||||
expect(isCosmeticCommit("chore(release): v0.9.2")).toBe(false);
|
|
||||||
expect(isCosmeticCommit("chore: tidy up the queue helper")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is not fooled by the word format appearing later in a subject", () => {
|
|
||||||
// A real fix to formatting *code* is not a cosmetic commit.
|
|
||||||
expect(isCosmeticCommit("fix(duration): format times over 24 hours correctly")).toBe(false);
|
|
||||||
expect(isCosmeticCommit("feat: add a format picker to settings")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles an empty or malformed subject without throwing", () => {
|
|
||||||
expect(isCosmeticCommit("")).toBe(false);
|
|
||||||
expect(isCosmeticCommit(" ")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -54,88 +54,13 @@ function loadRequirementDescriptions(): Map<string, string> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Commit subjects whose changes carry no requirement meaning.
|
|
||||||
*
|
|
||||||
* `chore(format)` / `style` rewrite files without changing behaviour;
|
|
||||||
* `chore(deps)` moves lockfiles. Anything else — including a bare `chore:` and
|
|
||||||
* `chore(release):` — is assumed to mean something and is kept.
|
|
||||||
*
|
|
||||||
* Anchored at the start of the subject on purpose: "fix(duration): format times
|
|
||||||
* over 24 hours" is a real fix to formatting *code*, not a formatting commit.
|
|
||||||
*/
|
|
||||||
const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does this commit subject describe a change with no requirement meaning?
|
|
||||||
*
|
|
||||||
* Exported for scripts/release-notes.test.ts.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-219
|
|
||||||
*/
|
|
||||||
export function isCosmeticCommit(subject: string): boolean {
|
|
||||||
return COSMETIC_SUBJECT.test(subject.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Files the range changed, excluding those touched only by cosmetic commits.
|
|
||||||
*
|
|
||||||
* Why not a plain `git diff --name-only <range>`: that is what this did, and a
|
|
||||||
* single repo-wide `prettier --write` inside the range made it report 199 files
|
|
||||||
* whose TRACES comments resolved to nearly the entire requirement matrix. The
|
|
||||||
* generated notes for v0.9.2 claimed the release had added the whole
|
|
||||||
* application — and build-release.yml publishes this output, so the noise would
|
|
||||||
* have shipped.
|
|
||||||
*
|
|
||||||
* Walking commit by commit and skipping the cosmetic ones keeps a file that a
|
|
||||||
* sweep *and* a real change both touched: it is still listed by the real
|
|
||||||
* commit. Only files touched exclusively by cosmetic commits drop out, which is
|
|
||||||
* exactly the intent.
|
|
||||||
*
|
|
||||||
* Merge commits produce no output from `git diff-tree` without `-m`, and are
|
|
||||||
* skipped deliberately: everything they merge is already in the range as its
|
|
||||||
* own commit, so including them would double-count.
|
|
||||||
*/
|
|
||||||
function changedFiles(range: string): string[] {
|
function changedFiles(range: string): string[] {
|
||||||
// Untagged repo: describe everything currently traced.
|
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
|
||||||
if (!range) {
|
return sh(cmd)
|
||||||
return sh("git ls-files")
|
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((f) => f && existsSync(f));
|
.filter((f) => f && existsSync(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUL between hash and subject so a subject containing anything at all is safe.
|
|
||||||
const log = sh(`git log --no-merges --format=%H%x00%s ${range}`);
|
|
||||||
if (!log) return [];
|
|
||||||
|
|
||||||
const files = new Set<string>();
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
for (const line of log.split("\n")) {
|
|
||||||
const [sha, ...subjectParts] = line.split("\u0000");
|
|
||||||
const subject = subjectParts.join("\u0000");
|
|
||||||
if (!sha) continue;
|
|
||||||
|
|
||||||
if (isCosmeticCommit(subject)) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) {
|
|
||||||
if (f && existsSync(f)) files.add(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipped > 0) {
|
|
||||||
// Say what was dropped rather than silently reporting a smaller set.
|
|
||||||
console.error(
|
|
||||||
`ℹ️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...files];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||||||
function idsFromFiles(files: string[]): Set<string> {
|
function idsFromFiles(files: string[]): Set<string> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -207,7 +132,4 @@ function main() {
|
|||||||
console.log(out.join("\n"));
|
console.log(out.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guarded so this module stays importable from release-notes.test.ts.
|
|
||||||
if (import.meta.main) {
|
|
||||||
main();
|
main();
|
||||||
}
|
|
||||||
|
|||||||
Generated
+374
-557
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.10.0"
|
version = "0.9.1"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
//! Publishes the Android JavaVM and application Context into `ndk_context`.
|
|
||||||
//!
|
|
||||||
//! TRACES: UR-012 | DR-223
|
|
||||||
//!
|
|
||||||
//! # Why this exists
|
|
||||||
//!
|
|
||||||
//! Seven places in this crate (five in `credentials.rs`, two in `lib.rs`) reach
|
|
||||||
//! the JNI environment through [`ndk_context::android_context`], which reads a
|
|
||||||
//! process-global pair of pointers: the `JavaVM` and a `Context` jobject.
|
|
||||||
//!
|
|
||||||
//! Nothing here ever set that global. `tao` did — the windowing layer beneath
|
|
||||||
//! `wry`, several dependencies below anything this project names. tao 0.34.5
|
|
||||||
//! called `ndk_context::initialize_android_context(...)` while starting the
|
|
||||||
//! Android activity, and our code simply read what it had left behind.
|
|
||||||
//!
|
|
||||||
//! **tao 0.35.3 stopped.** It keeps the same two pointers in a private
|
|
||||||
//! `AndroidContext` struct of its own and no longer publishes them. The moment
|
|
||||||
//! that landed (via the Tauri 2.9.5 → 2.11.5 upgrade), the first credential
|
|
||||||
//! read on Android aborted the process:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! PANIC at ndk-context/src/lib.rs:72: android context was not initialized
|
|
||||||
//! 8: ndk_context::android_context
|
|
||||||
//! 9: jellytau_lib::run::{{closure}}
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Not a crash in our code, and not a change to our code: an undocumented side
|
|
||||||
//! effect of a transitive dependency disappeared. The lesson worth keeping is
|
|
||||||
//! that relying on *someone else* to populate a global is a dependency you
|
|
||||||
//! cannot see in `Cargo.toml` and will not be told about when it breaks.
|
|
||||||
//!
|
|
||||||
//! # Why restore the global rather than rewrite the call sites
|
|
||||||
//!
|
|
||||||
//! Threading a VM and Context handle through seven call sites — including the
|
|
||||||
//! credential path — is a larger and riskier change than owning the invariant
|
|
||||||
//! those call sites already depend on. This module makes the assumption true
|
|
||||||
//! instead of removing it, and the seven callers are untouched.
|
|
||||||
//!
|
|
||||||
//! # How
|
|
||||||
//!
|
|
||||||
//! `JNI_OnLoad` gives us the `JavaVM` the instant the shared library loads,
|
|
||||||
//! which is the earliest and most reliable moment available — nothing in the app
|
|
||||||
//! can run before it. It does *not* give us a Context, so that is resolved
|
|
||||||
//! lazily on first use via `ActivityThread.currentApplication()`, by which point
|
|
||||||
//! the Application object certainly exists.
|
|
||||||
//!
|
|
||||||
//! The Context published is the **Application**, not the Activity. That is what
|
|
||||||
//! the consumers want anyway (`SecureStorage.initialize()` immediately calls
|
|
||||||
//! `context.applicationContext`), and it cannot outlive its own lifetime the way
|
|
||||||
//! a retained Activity reference would.
|
|
||||||
|
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::sync::atomic::{AtomicPtr, Ordering};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use jni::objects::GlobalRef;
|
|
||||||
use jni::sys::{jint, JNI_VERSION_1_6};
|
|
||||||
use jni::JavaVM;
|
|
||||||
|
|
||||||
/// The `JavaVM`, captured at library load.
|
|
||||||
static JAVA_VM: AtomicPtr<c_void> = AtomicPtr::new(std::ptr::null_mut());
|
|
||||||
|
|
||||||
/// A global reference to the Application, kept alive for the process lifetime.
|
|
||||||
///
|
|
||||||
/// `ndk_context` stores a bare pointer and does not own the reference, so the
|
|
||||||
/// `GlobalRef` must outlive every read. A local reference would be freed the
|
|
||||||
/// moment the frame that created it returned, leaving a dangling jobject that
|
|
||||||
/// only misbehaves later.
|
|
||||||
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Whether the `ndk_context` global has been populated.
|
|
||||||
static PUBLISHED: OnceLock<bool> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Called by the Android runtime when `libjellytau_lib.so` is loaded.
|
|
||||||
///
|
|
||||||
/// Verified that neither tao, wry nor tauri defines `JNI_OnLoad` in this
|
|
||||||
/// library, so there is nothing to collide with. Returning the JNI version is
|
|
||||||
/// mandatory — returning 0 makes `System.loadLibrary` fail.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-012 | DR-223
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint {
|
|
||||||
JAVA_VM.store(vm.get_java_vm_pointer().cast(), Ordering::SeqCst);
|
|
||||||
// Deliberately no logging here: the logger is not installed this early.
|
|
||||||
JNI_VERSION_1_6
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make [`ndk_context::android_context`] safe to call.
|
|
||||||
///
|
|
||||||
/// Idempotent and cheap after the first success. Returns an error rather than
|
|
||||||
/// panicking: a failure here means credentials fall back to the encrypted-file
|
|
||||||
/// path, which is a degraded mode the app already supports — far better than
|
|
||||||
/// aborting the process, which is what the missing global did.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-012 | DR-223
|
|
||||||
pub fn ensure_initialized() -> Result<(), String> {
|
|
||||||
if PUBLISHED.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let vm_ptr = JAVA_VM.load(Ordering::SeqCst);
|
|
||||||
if vm_ptr.is_null() {
|
|
||||||
return Err(
|
|
||||||
"JNI_OnLoad has not run: no JavaVM captured. The library was loaded in an \
|
|
||||||
unexpected way, or JNI_OnLoad was stripped from the shared object."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
|
||||||
.map_err(|e| format!("failed to adopt the JavaVM pointer: {e}"))?;
|
|
||||||
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread()
|
|
||||||
.map_err(|e| format!("failed to attach the current thread to the JVM: {e}"))?;
|
|
||||||
|
|
||||||
// ActivityThread.currentApplication() is the standard way to reach the
|
|
||||||
// Application from native code without being handed a Context. It is a
|
|
||||||
// hidden-but-stable API; it returns null only before the Application is
|
|
||||||
// constructed, which cannot be the case by the time anything here runs.
|
|
||||||
let activity_thread = env
|
|
||||||
.find_class("android/app/ActivityThread")
|
|
||||||
.map_err(|e| format!("android.app.ActivityThread not found: {e}"))?;
|
|
||||||
|
|
||||||
let application = env
|
|
||||||
.call_static_method(
|
|
||||||
activity_thread,
|
|
||||||
"currentApplication",
|
|
||||||
"()Landroid/app/Application;",
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("ActivityThread.currentApplication() failed: {e}"))?
|
|
||||||
.l()
|
|
||||||
.map_err(|e| format!("currentApplication() did not return an object: {e}"))?;
|
|
||||||
|
|
||||||
if application.is_null() {
|
|
||||||
return Err(
|
|
||||||
"ActivityThread.currentApplication() returned null — the Application has not \
|
|
||||||
been created yet."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let global = env
|
|
||||||
.new_global_ref(&application)
|
|
||||||
.map_err(|e| format!("failed to pin the Application as a global reference: {e}"))?;
|
|
||||||
|
|
||||||
// Store first, publish second: `ndk_context` will hold a bare pointer into
|
|
||||||
// this reference, so it must already be owned somewhere permanent.
|
|
||||||
let stored = APP_CONTEXT.get_or_init(|| global);
|
|
||||||
let context_ptr = stored.as_obj().as_raw().cast::<c_void>();
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
ndk_context::initialize_android_context(vm_ptr, context_ptr);
|
|
||||||
}
|
|
||||||
let _ = PUBLISHED.set(true);
|
|
||||||
|
|
||||||
log::info!("[INIT] Android JavaVM and Application published to ndk_context");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the global has been published, for callers that want to degrade
|
|
||||||
/// rather than attempt a JNI call.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_initialized() -> bool {
|
|
||||||
PUBLISHED.get().is_some()
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ use crate::player::{
|
|||||||
};
|
};
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
types::{GetItemsOptions, ImageOptions, ImageType},
|
types::{GetItemsOptions, ImageOptions, ImageType},
|
||||||
MediaRepository, StreamSelection,
|
MediaRepository,
|
||||||
};
|
};
|
||||||
use crate::settings::VideoSettings;
|
use crate::settings::VideoSettings;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
@@ -179,18 +179,6 @@ pub struct PlayItemRequest {
|
|||||||
pub video_codec: String,
|
pub video_codec: String,
|
||||||
/// Whether the video requires server-side transcoding
|
/// Whether the video requires server-side transcoding
|
||||||
pub needs_transcoding: bool,
|
pub needs_transcoding: bool,
|
||||||
/// How this item's stream is fetched, as the backend decided it.
|
|
||||||
///
|
|
||||||
/// Carried on the queue item so a later seek/reload does not have to guess.
|
|
||||||
/// `None` for items queued by a path that never negotiated (audio tracks,
|
|
||||||
/// direct URLs) and for anything queued before this field existed, where the
|
|
||||||
/// caller falls back to `needs_transcoding` — every transcode this app
|
|
||||||
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
|
|
||||||
#[serde(default)]
|
|
||||||
pub transport: Option<crate::repository::Transport>,
|
|
||||||
|
|
||||||
/// Optional now-playing metadata. Used by the background-audio handoff so the
|
/// Optional now-playing metadata. Used by the background-audio handoff so the
|
||||||
/// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
/// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||||
/// existing video-only callers need not send them.
|
/// existing video-only callers need not send them.
|
||||||
@@ -329,15 +317,9 @@ pub enum VideoSeekResponse {
|
|||||||
},
|
},
|
||||||
/// Reload stream from new position (transcoded non-HLS)
|
/// Reload stream from new position (transcoded non-HLS)
|
||||||
ReloadStream {
|
ReloadStream {
|
||||||
/// What to open, and how — transport included, so the frontend picks
|
/// New stream URL starting at seek position
|
||||||
/// its loader from a tagged enum rather than by searching the URL for
|
new_url: String,
|
||||||
/// `.m3u8`. TRACES: UR-079 | DR-224
|
/// Position offset to track (for display purposes)
|
||||||
selection: StreamSelection,
|
|
||||||
/// `seek_offset` carries the position to RESUME AT, not a base to add to
|
|
||||||
/// the element's clock. The reloaded stream starts at the item's zero —
|
|
||||||
/// a position on an HLS playlist makes the server 400 every segment
|
|
||||||
/// behind it (DR-181) — so the adapter reaches the position by seeking
|
|
||||||
/// the element and leaves the transcode offset at zero.
|
|
||||||
seek_offset: f64,
|
seek_offset: f64,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,8 +335,8 @@ pub enum AudioTrackSwitchResponse {
|
|||||||
},
|
},
|
||||||
/// HTML5 needs to reload stream with new audio track
|
/// HTML5 needs to reload stream with new audio track
|
||||||
ReloadStream {
|
ReloadStream {
|
||||||
/// What to open, and how. TRACES: UR-079 | DR-224
|
/// New stream URL with selected audio track
|
||||||
selection: StreamSelection,
|
new_url: String,
|
||||||
/// Current position to resume from
|
/// Current position to resume from
|
||||||
position: f64,
|
position: f64,
|
||||||
},
|
},
|
||||||
@@ -374,13 +356,10 @@ pub enum StreamQualityResponse {
|
|||||||
/// Position playback resumed at.
|
/// Position playback resumed at.
|
||||||
position: f64,
|
position: f64,
|
||||||
},
|
},
|
||||||
/// HTML5 must reload its element with this selection.
|
/// HTML5 must reload its element with this URL.
|
||||||
ReloadStream {
|
ReloadStream {
|
||||||
/// What to open, and how — already negotiated against the requested
|
/// New stream URL, already transcoded to the requested ceiling.
|
||||||
/// ceiling. Carries `available` too, so a picker opened after a quality
|
new_url: String,
|
||||||
/// change still describes the source correctly.
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-224, DR-226
|
|
||||||
selection: StreamSelection,
|
|
||||||
/// Position to resume from.
|
/// Position to resume from.
|
||||||
position: f64,
|
position: f64,
|
||||||
},
|
},
|
||||||
@@ -436,8 +415,6 @@ pub(super) async fn create_media_item(
|
|||||||
source,
|
source,
|
||||||
video_codec: Some(req.video_codec),
|
video_codec: Some(req.video_codec),
|
||||||
needs_transcoding: req.needs_transcoding,
|
needs_transcoding: req.needs_transcoding,
|
||||||
// The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-229
|
|
||||||
transport: req.transport,
|
|
||||||
video_width: None, // Not available from video-only request
|
video_width: None, // Not available from video-only request
|
||||||
video_height: None, // Not available from video-only request
|
video_height: None, // Not available from video-only request
|
||||||
// Sideloaded subtitles, in the order the frontend sent them — that order
|
// Sideloaded subtitles, in the order the frontend sent them — that order
|
||||||
@@ -686,14 +663,6 @@ pub async fn player_play_item(
|
|||||||
item.title, item.stream_url
|
item.title, item.stream_url
|
||||||
);
|
);
|
||||||
|
|
||||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
|
||||||
// chosen for. Starting a different item returns to the device default —
|
|
||||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
|
||||||
// session, which is the defect DR-225 exists to close.
|
|
||||||
//
|
|
||||||
// TRACES: UR-074, UR-079 | DR-225
|
|
||||||
crate::repository::online::clear_playback_quality_override();
|
|
||||||
|
|
||||||
// Create media item, checking for local download first
|
// Create media item, checking for local download first
|
||||||
let media_item = create_media_item(item, Some(&db)).await?;
|
let media_item = create_media_item(item, Some(&db)).await?;
|
||||||
|
|
||||||
@@ -793,8 +762,6 @@ pub async fn player_enter_background_audio(
|
|||||||
// create_media_item() because that hardcodes MediaType::Video; background
|
// create_media_item() because that hardcodes MediaType::Video; background
|
||||||
// audio must be Audio so no video decode is started.
|
// audio must be Audio so no video decode is started.
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
title: item.title.clone(),
|
title: item.title.clone(),
|
||||||
name: Some(item.title.clone()),
|
name: Some(item.title.clone()),
|
||||||
@@ -928,14 +895,6 @@ pub async fn player_play_queue(
|
|||||||
request.shuffle
|
request.shuffle
|
||||||
);
|
);
|
||||||
|
|
||||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
|
||||||
// chosen for. Starting a different item returns to the device default —
|
|
||||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
|
||||||
// session, which is the defect DR-225 exists to close.
|
|
||||||
//
|
|
||||||
// TRACES: UR-074, UR-079 | DR-225
|
|
||||||
crate::repository::online::clear_playback_quality_override();
|
|
||||||
|
|
||||||
// Handle shuffle first
|
// Handle shuffle first
|
||||||
if request.shuffle {
|
if request.shuffle {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
@@ -1348,7 +1307,7 @@ pub async fn player_seek(
|
|||||||
///
|
///
|
||||||
/// This command analyzes the current video stream and automatically chooses
|
/// This command analyzes the current video stream and automatically chooses
|
||||||
/// the best seeking strategy:
|
/// the best seeking strategy:
|
||||||
/// - HLS streams: Use native seeking
|
/// - HLS streams (.m3u8): Use native seeking
|
||||||
/// - Direct play streams: Use native seeking
|
/// - Direct play streams: Use native seeking
|
||||||
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||||
///
|
///
|
||||||
@@ -1378,7 +1337,7 @@ pub async fn player_seek_video(
|
|||||||
|
|
||||||
// Get current playing item to analyze stream characteristics
|
// Get current playing item to analyze stream characteristics
|
||||||
// Clone what we need to avoid holding locks across await points
|
// Clone what we need to avoid holding locks across await points
|
||||||
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
|
let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
let queue_arc = controller.queue();
|
let queue_arc = controller.queue();
|
||||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1394,27 +1353,18 @@ pub async fn player_seek_video(
|
|||||||
.ok_or("Current video has no Jellyfin ID")?
|
.ok_or("Current video has no Jellyfin ID")?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// The URL itself is no longer read here: the seek strategy now comes
|
let (stream_url, is_local_file) = match ¤t_item.source {
|
||||||
// from the item's own `transport`, not from inspecting the string.
|
MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
|
||||||
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
|
MediaSource::Local { .. } => (String::new(), true),
|
||||||
|
MediaSource::DirectUrl { url } => (url.clone(), false),
|
||||||
|
};
|
||||||
|
|
||||||
let needs_trans = current_item.needs_transcoding;
|
let needs_trans = current_item.needs_transcoding;
|
||||||
let transport = current_item.transport;
|
(needs_trans, jellyfin_id, stream_url, is_local_file)
|
||||||
(needs_trans, jellyfin_id, is_local_file, transport)
|
|
||||||
}; // Locks are dropped here
|
}; // Locks are dropped here
|
||||||
|
|
||||||
// The transport comes from the backend's own decision, not from searching
|
// Determine seek strategy using the testable helper function
|
||||||
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
|
let is_hls = stream_url.contains(".m3u8");
|
||||||
// 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-224, DR-229
|
|
||||||
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, is_hls, 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={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
|
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||||
@@ -1438,22 +1388,29 @@ pub async fn player_seek_video(
|
|||||||
// Transcoded non-HLS with HTML5 - frontend handles stream reload
|
// Transcoded non-HLS with HTML5 - frontend handles stream reload
|
||||||
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
|
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
|
||||||
|
|
||||||
let selection = repository
|
let new_url = repository
|
||||||
.get_stream_selection(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"[player_seek_video] Selected {:?} over {:?} for position {}",
|
"[player_seek_video] Got new stream URL for position {}",
|
||||||
selection.playback_kind, selection.transport, position
|
position
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// `seek_offset` carries the position to RESUME AT, not a base to add
|
||||||
|
// to the element's clock. The reloaded stream starts at the item's
|
||||||
|
// zero — a position on an HLS playlist makes the server 400 every
|
||||||
|
// segment behind it (DR-181) — so the adapter reaches the position by
|
||||||
|
// seeking the element and leaves the transcode offset at zero. The
|
||||||
|
// field keeps its name only because renaming it means regenerating
|
||||||
|
// the specta bindings; `reloadSource` documents the contract.
|
||||||
Ok(VideoSeekResponse::ReloadStream {
|
Ok(VideoSeekResponse::ReloadStream {
|
||||||
selection,
|
new_url,
|
||||||
seek_offset: position,
|
seek_offset: position,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1461,17 +1418,16 @@ pub async fn player_seek_video(
|
|||||||
// Transcoded non-HLS with native backend - backend handles stream reload
|
// Transcoded non-HLS with native backend - backend handles stream reload
|
||||||
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
|
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
|
||||||
|
|
||||||
let selection = repository
|
let new_url = repository
|
||||||
.get_stream_selection(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||||
let new_url = selection.url.clone();
|
|
||||||
|
|
||||||
info!("[player_seek_video] Got new selection, handling reload internally");
|
info!("[player_seek_video] Got new stream URL, handling reload internally");
|
||||||
|
|
||||||
// Stop current playback
|
// Stop current playback
|
||||||
{
|
{
|
||||||
@@ -1572,25 +1528,20 @@ pub async fn player_switch_audio_track(
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
// Get new stream URL with selected audio track. It starts at zero — an
|
||||||
// an HLS playlist cannot carry a position (DR-181) — and `position`
|
// HLS playlist cannot carry a position (DR-181) — and `position` below
|
||||||
// below tells the frontend where to seek the reloaded element back to.
|
// tells the frontend where to seek the reloaded element back to.
|
||||||
//
|
let new_url = repository
|
||||||
// Pinning a track is itself a reason the source cannot be direct-played:
|
.get_video_stream_url(
|
||||||
// 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,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
Some(stream_index),
|
Some(stream_index),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||||
|
|
||||||
Ok(AudioTrackSwitchResponse::ReloadStream {
|
Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||||
selection,
|
new_url,
|
||||||
position: current_position.unwrap_or(0.0),
|
position: current_position.unwrap_or(0.0),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -1613,26 +1564,23 @@ pub async fn player_switch_audio_track(
|
|||||||
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||||
/// native backend is reloaded here.
|
/// native backend is reloaded here.
|
||||||
///
|
///
|
||||||
/// The change applies to **this playback only**. The in-player picker is a
|
/// The change applies to this playback *and* to everything started afterwards
|
||||||
/// "this film, this connection" control and its doc has always said so, but it
|
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||||
/// used to be implemented by writing the process-wide ceiling — so choosing
|
/// the in-player picker is a "this film, this connection" control, and the
|
||||||
/// 2 Mbps to get one awkward film moving silently capped every video played
|
/// durable default belongs to Settings. `player_set_video_settings` is the one
|
||||||
/// afterwards for the rest of the process, with the Settings screen still
|
/// that writes to the database.
|
||||||
/// showing the old value and nothing in the UI admitting the change. It now
|
|
||||||
/// sets a per-playback override that the next item clears; the durable default
|
|
||||||
/// belongs to Settings, and `player_set_video_settings` is the one that writes
|
|
||||||
/// to the database.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-074, UR-079 | DR-162, DR-225
|
/// TRACES: UR-074 | DR-162
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller
|
||||||
// input. Folding the rest into a struct would change the IPC contract and the
|
// input. Folding the rest into a struct would change the IPC contract and the
|
||||||
// generated TypeScript for no readability gain.
|
// generated TypeScript for no readability gain.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn player_set_stream_quality(
|
pub async fn player_set_stream_quality(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
quality: crate::settings::StreamingQuality,
|
quality: crate::settings::StreamingQuality,
|
||||||
use_html5: bool,
|
use_html5: bool,
|
||||||
@@ -1669,31 +1617,25 @@ pub async fn player_set_stream_quality(
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set the ceiling *before* negotiating — the negotiation and every URL
|
// Set the ceiling *before* building the URL — the builder reads it.
|
||||||
// builder resolve through `effective_streaming_quality`, and they have to
|
crate::repository::online::set_streaming_quality(quality);
|
||||||
// agree or the cap leaks (a negotiation authorising a direct play the URL
|
{
|
||||||
// builder then never gets to constrain).
|
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||||
//
|
settings.streaming_quality = quality;
|
||||||
// Deliberately the *override*, not the device default: see the doc above.
|
}
|
||||||
// TRACES: UR-074, UR-079 | DR-225
|
|
||||||
crate::repository::online::set_playback_quality_override(quality);
|
|
||||||
|
|
||||||
let position = current_position.unwrap_or(0.0);
|
let position = current_position.unwrap_or(0.0);
|
||||||
let selection = repository
|
let new_url = repository
|
||||||
.get_stream_selection(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||||
let new_url = selection.url.clone();
|
|
||||||
|
|
||||||
if use_html5 {
|
if use_html5 {
|
||||||
return Ok(StreamQualityResponse::ReloadStream {
|
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
|
||||||
selection,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||||
@@ -2226,8 +2168,6 @@ pub async fn player_play_album_track(
|
|||||||
|
|
||||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
@@ -2373,14 +2313,6 @@ pub async fn player_play_tracks(
|
|||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: PlayTracksRequest,
|
request: PlayTracksRequest,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
|
||||||
// chosen for. Starting a different item returns to the device default —
|
|
||||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
|
||||||
// session, which is the defect DR-225 exists to close.
|
|
||||||
//
|
|
||||||
// TRACES: UR-074, UR-079 | DR-225
|
|
||||||
crate::repository::online::clear_playback_quality_override();
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"player_play_tracks called: {} tracks, start_index={}, shuffle={}",
|
"player_play_tracks called: {} tracks, start_index={}, shuffle={}",
|
||||||
request.track_ids.len(),
|
request.track_ids.len(),
|
||||||
@@ -2432,8 +2364,6 @@ pub async fn player_play_tracks(
|
|||||||
// Transform to MediaItem with frontend-compatible fields
|
// Transform to MediaItem with frontend-compatible fields
|
||||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
@@ -3240,8 +3170,6 @@ mod tests {
|
|||||||
let db = DatabaseWrapper(Mutex::new(database));
|
let db = DatabaseWrapper(Mutex::new(database));
|
||||||
|
|
||||||
let make_item = |id: &str| MediaItem {
|
let make_item = |id: &str| MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: id.to_string(),
|
title: id.to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
|
|||||||
@@ -198,8 +198,6 @@ pub async fn player_add_track_by_id(
|
|||||||
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
@@ -319,8 +317,6 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::domain::rank_search_results;
|
|||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||||
OnlineRepository, StreamSelection,
|
OnlineRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Repository handle manager
|
/// Repository handle manager
|
||||||
@@ -606,35 +606,6 @@ pub async fn repository_get_video_stream_url(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide what stream to play for a video, and describe it.
|
|
||||||
///
|
|
||||||
/// Replaces `repository_get_video_stream_url` for playback. The returned
|
|
||||||
/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
|
|
||||||
/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
|
|
||||||
/// carries the quality ladder as it applies to *this* source, so the picker can
|
|
||||||
/// stop offering rungs that produce the same bytes as Original.
|
|
||||||
///
|
|
||||||
/// No start-position parameter, for the same reason as the URL builder: a
|
|
||||||
/// position on an HLS playlist is copied onto every segment URI and the server
|
|
||||||
/// rejects each with `400` (DR-181). Callers resume by seeking after load.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227 | UT-212
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn repository_get_stream_selection(
|
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
|
||||||
handle: String,
|
|
||||||
item_id: String,
|
|
||||||
media_source_id: Option<String>,
|
|
||||||
audio_stream_index: Option<i32>,
|
|
||||||
) -> Result<StreamSelection, String> {
|
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
||||||
repo.as_ref()
|
|
||||||
.get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||||
///
|
///
|
||||||
/// TRACES: UR-040 | JA-032 | UT-061
|
/// TRACES: UR-040 | JA-032 | UT-061
|
||||||
|
|||||||
@@ -104,30 +104,6 @@ pub fn media_local_url(
|
|||||||
.ok_or_else(|| "Local media server is not running".to_string())
|
.ok_or_else(|| "Local media server is not running".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream selection for a downloaded file.
|
|
||||||
///
|
|
||||||
/// The local-playback counterpart to `repository_get_stream_selection`. A file
|
|
||||||
/// on disk needs no negotiation — it is a direct play over a local transport,
|
|
||||||
/// with no quality ladder, because nothing about it can be re-negotiated — but
|
|
||||||
/// the *frontend must not be the one to say so*. It gets the same
|
|
||||||
/// [`StreamSelection`] shape as a streamed source so the player has one contract
|
|
||||||
/// to consume rather than two, and so no caller has to infer a transport from a
|
|
||||||
/// loopback URL.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-071, UR-079 | DR-224
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn media_local_selection(
|
|
||||||
server: State<crate::media_server::MediaServerWrapper>,
|
|
||||||
path: String,
|
|
||||||
) -> Result<crate::repository::StreamSelection, String> {
|
|
||||||
server
|
|
||||||
.0
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| crate::repository::StreamSelection::local_file(s.url_for(&path)))
|
|
||||||
.ok_or_else(|| "Local media server is not running".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get storage directory path (parent directory of the database file)
|
/// Get storage directory path (parent directory of the database file)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#[cfg(target_os = "android")]
|
|
||||||
mod android_context;
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod connectivity;
|
mod connectivity;
|
||||||
@@ -96,7 +94,6 @@ use commands::{
|
|||||||
lms_unsync_player,
|
lms_unsync_player,
|
||||||
mark_download_completed,
|
mark_download_completed,
|
||||||
mark_download_failed,
|
mark_download_failed,
|
||||||
media_local_selection,
|
|
||||||
media_local_url,
|
media_local_url,
|
||||||
offline_get_items,
|
offline_get_items,
|
||||||
offline_is_available,
|
offline_is_available,
|
||||||
@@ -224,7 +221,6 @@ use commands::{
|
|||||||
repository_get_series_current_episode,
|
repository_get_series_current_episode,
|
||||||
repository_get_series_episodes,
|
repository_get_series_episodes,
|
||||||
repository_get_similar_items,
|
repository_get_similar_items,
|
||||||
repository_get_stream_selection,
|
|
||||||
repository_get_subtitle_url,
|
repository_get_subtitle_url,
|
||||||
repository_get_video_download_url,
|
repository_get_video_download_url,
|
||||||
repository_get_video_stream_url,
|
repository_get_video_stream_url,
|
||||||
@@ -618,14 +614,6 @@ fn create_player_backend(
|
|||||||
{
|
{
|
||||||
info!("Android platform detected - initializing ExoPlayer backend");
|
info!("Android platform detected - initializing ExoPlayer backend");
|
||||||
|
|
||||||
// Same precondition as the credential path: ndk_context must be
|
|
||||||
// populated before it is read, and nothing outside this crate populates
|
|
||||||
// it any more. Idempotent, so it does not matter which of the two runs
|
|
||||||
// first. TRACES: UR-012 | DR-223
|
|
||||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
|
||||||
log::error!("[INIT] Android context unavailable for the player: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the Android context via ndk-context
|
// Get the Android context via ndk-context
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
|
|
||||||
@@ -898,7 +886,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
mark_download_completed,
|
mark_download_completed,
|
||||||
mark_download_failed,
|
mark_download_failed,
|
||||||
media_local_url,
|
media_local_url,
|
||||||
media_local_selection,
|
|
||||||
start_download,
|
start_download,
|
||||||
enqueue_download,
|
enqueue_download,
|
||||||
enqueue_video_downloads,
|
enqueue_video_downloads,
|
||||||
@@ -985,7 +972,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_search,
|
repository_search,
|
||||||
repository_get_playback_info,
|
repository_get_playback_info,
|
||||||
repository_get_video_stream_url,
|
repository_get_video_stream_url,
|
||||||
repository_get_stream_selection,
|
|
||||||
repository_get_audio_stream_url,
|
repository_get_audio_stream_url,
|
||||||
repository_get_audio_only_stream_url_for_video,
|
repository_get_audio_only_stream_url_for_video,
|
||||||
repository_get_live_tv_channels,
|
repository_get_live_tv_channels,
|
||||||
@@ -1284,21 +1270,6 @@ pub fn run() {
|
|||||||
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
// Publish the JavaVM and Application into ndk_context first.
|
|
||||||
//
|
|
||||||
// Everything below reads that global. tao used to populate it
|
|
||||||
// and stopped doing so in 0.35 (Tauri 2.11), at which point the
|
|
||||||
// first read here aborted the process on launch. See
|
|
||||||
// android_context.rs. A failure is logged rather than fatal:
|
|
||||||
// credentials then fall back to the encrypted-file path, which
|
|
||||||
// is a supported degraded mode -- unlike aborting.
|
|
||||||
//
|
|
||||||
// TRACES: UR-012 | DR-223
|
|
||||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
|
||||||
log::error!("[INIT] Android context unavailable: {e}");
|
|
||||||
log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||||
|
|||||||
@@ -1125,8 +1125,6 @@ mod tests {
|
|||||||
|
|
||||||
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: format!("Track {}", id),
|
title: format!("Track {}", id),
|
||||||
name: Some(format!("Track {}", id)),
|
name: Some(format!("Track {}", id)),
|
||||||
@@ -1159,8 +1157,6 @@ mod tests {
|
|||||||
|
|
||||||
fn create_test_item_local(id: &str) -> MediaItem {
|
fn create_test_item_local(id: &str) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: format!("Local Track {}", id),
|
title: format!("Local Track {}", id),
|
||||||
name: Some(format!("Local Track {}", id)),
|
name: Some(format!("Local Track {}", id)),
|
||||||
|
|||||||
@@ -377,8 +377,6 @@ mod tests {
|
|||||||
|
|
||||||
// Create a test media item
|
// Create a test media item
|
||||||
let media = MediaItem {
|
let media = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "test_media".to_string(),
|
id: "test_media".to_string(),
|
||||||
title: "Test Track".to_string(),
|
title: "Test Track".to_string(),
|
||||||
name: Some("Test Track".to_string()),
|
name: Some("Test Track".to_string()),
|
||||||
@@ -438,8 +436,6 @@ mod tests {
|
|||||||
let mut backend = NullBackend::new();
|
let mut backend = NullBackend::new();
|
||||||
|
|
||||||
let media = MediaItem {
|
let media = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "test_media".to_string(),
|
id: "test_media".to_string(),
|
||||||
title: "Test Track".to_string(),
|
title: "Test Track".to_string(),
|
||||||
name: Some("Test Track".to_string()),
|
name: Some("Test Track".to_string()),
|
||||||
@@ -493,8 +489,6 @@ mod tests {
|
|||||||
let mut backend = NullBackend::new();
|
let mut backend = NullBackend::new();
|
||||||
|
|
||||||
let media = MediaItem {
|
let media = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "test_media".to_string(),
|
id: "test_media".to_string(),
|
||||||
title: "Test Track".to_string(),
|
title: "Test Track".to_string(),
|
||||||
name: Some("Test Track".to_string()),
|
name: Some("Test Track".to_string()),
|
||||||
|
|||||||
@@ -115,18 +115,6 @@ pub struct MediaItem {
|
|||||||
/// Whether the video requires server-side transcoding
|
/// Whether the video requires server-side transcoding
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub needs_transcoding: bool,
|
pub needs_transcoding: bool,
|
||||||
/// How this item's stream is fetched, as the backend decided it.
|
|
||||||
///
|
|
||||||
/// Carried on the queue item so a later seek/reload does not have to guess.
|
|
||||||
/// `None` for items queued by a path that never negotiated (audio tracks,
|
|
||||||
/// direct URLs) and for anything queued before this field existed, where the
|
|
||||||
/// caller falls back to `needs_transcoding` — every transcode this app
|
|
||||||
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
|
|
||||||
#[serde(default)]
|
|
||||||
pub transport: Option<crate::repository::Transport>,
|
|
||||||
|
|
||||||
/// Video width in pixels
|
/// Video width in pixels
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub video_width: Option<u32>,
|
pub video_width: Option<u32>,
|
||||||
@@ -372,8 +360,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_media_item_creation_minimal() {
|
fn test_media_item_creation_minimal() {
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "item-1".to_string(),
|
id: "item-1".to_string(),
|
||||||
title: "Test Item".to_string(),
|
title: "Test Item".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -410,8 +396,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_media_item_jellyfin_id() {
|
fn test_media_item_jellyfin_id() {
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "item-2".to_string(),
|
id: "item-2".to_string(),
|
||||||
title: "Test".to_string(),
|
title: "Test".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -447,8 +431,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_media_item_jellyfin_id_local() {
|
fn test_media_item_jellyfin_id_local() {
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "item-3".to_string(),
|
id: "item-3".to_string(),
|
||||||
title: "Local".to_string(),
|
title: "Local".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -484,8 +466,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_media_item_jellyfin_id_direct_url() {
|
fn test_media_item_jellyfin_id_direct_url() {
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "item-4".to_string(),
|
id: "item-4".to_string(),
|
||||||
title: "Direct".to_string(),
|
title: "Direct".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -528,8 +508,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "item-subs".to_string(),
|
id: "item-subs".to_string(),
|
||||||
title: "With Subs".to_string(),
|
title: "With Subs".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -565,8 +543,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_media_item_serialization() {
|
fn test_media_item_serialization() {
|
||||||
let item = MediaItem {
|
let item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "serial-item".to_string(),
|
id: "serial-item".to_string(),
|
||||||
title: "Serial Test".to_string(),
|
title: "Serial Test".to_string(),
|
||||||
name: Some("Name".to_string()),
|
name: Some("Name".to_string()),
|
||||||
|
|||||||
@@ -1896,8 +1896,6 @@ impl PlayerController {
|
|||||||
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
||||||
|
|
||||||
let media_item = MediaItem {
|
let media_item = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: next.id.clone(),
|
id: next.id.clone(),
|
||||||
title: next.name.clone(),
|
title: next.name.clone(),
|
||||||
name: Some(next.name.clone()),
|
name: Some(next.name.clone()),
|
||||||
@@ -2581,8 +2579,6 @@ mod tests {
|
|||||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||||
(0..count)
|
(0..count)
|
||||||
.map(|i| MediaItem {
|
.map(|i| MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: format!("item_{}", i),
|
id: format!("item_{}", i),
|
||||||
title: format!("Track {}", i + 1),
|
title: format!("Track {}", i + 1),
|
||||||
name: Some(format!("Track {}", i + 1)),
|
name: Some(format!("Track {}", i + 1)),
|
||||||
@@ -3795,8 +3791,6 @@ mod tests {
|
|||||||
|
|
||||||
// Queue holds the episode that just finished playing
|
// Queue holds the episode that just finished playing
|
||||||
let episode = MediaItem {
|
let episode = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
media_type: MediaType::Video,
|
media_type: MediaType::Video,
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||||
@@ -3832,8 +3826,6 @@ mod tests {
|
|||||||
|
|
||||||
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
||||||
let episode = MediaItem {
|
let episode = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
item_type: Some("Episode".to_string()),
|
item_type: Some("Episode".to_string()),
|
||||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||||
series_id: Some("series1".to_string()),
|
series_id: Some("series1".to_string()),
|
||||||
@@ -3950,8 +3942,6 @@ mod tests {
|
|||||||
|
|
||||||
// Currently playing: ep2 handed off to audio-only background playback.
|
// Currently playing: ep2 handed off to audio-only background playback.
|
||||||
let episode = MediaItem {
|
let episode = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "ep2".to_string(),
|
id: "ep2".to_string(),
|
||||||
item_type: Some("Episode".to_string()),
|
item_type: Some("Episode".to_string()),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -3987,8 +3977,6 @@ mod tests {
|
|||||||
/// URL carrying the handoff position.
|
/// URL carrying the handoff position.
|
||||||
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "ep2".to_string(),
|
id: "ep2".to_string(),
|
||||||
item_type: Some("Episode".to_string()),
|
item_type: Some("Episode".to_string()),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -4009,8 +3997,6 @@ mod tests {
|
|||||||
/// handoff point.
|
/// handoff point.
|
||||||
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
source: MediaSource::Local {
|
source: MediaSource::Local {
|
||||||
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
|
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
|
||||||
jellyfin_item_id: Some("ep2".to_string()),
|
jellyfin_item_id: Some("ep2".to_string()),
|
||||||
@@ -4695,8 +4681,6 @@ mod tests {
|
|||||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
let episode = MediaItem {
|
let episode = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "ep2".to_string(),
|
id: "ep2".to_string(),
|
||||||
item_type: Some("Episode".to_string()),
|
item_type: Some("Episode".to_string()),
|
||||||
media_type: MediaType::Video,
|
media_type: MediaType::Video,
|
||||||
@@ -4732,8 +4716,6 @@ mod tests {
|
|||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
let episode = MediaItem {
|
let episode = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
media_type: MediaType::Video,
|
media_type: MediaType::Video,
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||||
|
|||||||
@@ -541,8 +541,6 @@ mod tests {
|
|||||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||||
(0..count)
|
(0..count)
|
||||||
.map(|i| MediaItem {
|
.map(|i| MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: format!("item_{}", i),
|
id: format!("item_{}", i),
|
||||||
title: format!("Track {}", i + 1),
|
title: format!("Track {}", i + 1),
|
||||||
name: Some(format!("Track {}", i + 1)),
|
name: Some(format!("Track {}", i + 1)),
|
||||||
|
|||||||
@@ -232,8 +232,6 @@ mod tests {
|
|||||||
|
|
||||||
fn create_test_audio_item(title: &str) -> MediaItem {
|
fn create_test_audio_item(title: &str) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: title.to_string(),
|
id: title.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
name: Some(title.to_string()),
|
name: Some(title.to_string()),
|
||||||
@@ -265,8 +263,6 @@ mod tests {
|
|||||||
|
|
||||||
fn create_test_movie_item(title: &str) -> MediaItem {
|
fn create_test_movie_item(title: &str) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: title.to_string(),
|
id: title.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
name: Some(title.to_string()),
|
name: Some(title.to_string()),
|
||||||
|
|||||||
@@ -316,8 +316,6 @@ mod tests {
|
|||||||
// Helper function to create test MediaItem instances
|
// Helper function to create test MediaItem instances
|
||||||
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
|
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
|
|||||||
@@ -258,8 +258,6 @@ mod tests {
|
|||||||
/// `StartTimeTicks` is the handoff point.
|
/// `StartTimeTicks` is the handoff point.
|
||||||
fn handoff_item() -> MediaItem {
|
fn handoff_item() -> MediaItem {
|
||||||
MediaItem {
|
MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
id: "ep2".to_string(),
|
id: "ep2".to_string(),
|
||||||
title: "Episode 2".to_string(),
|
title: "Episode 2".to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -305,8 +303,6 @@ mod tests {
|
|||||||
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
||||||
// ranges, so ExoPlayer resumes it where the load failed.
|
// ranges, so ExoPlayer resumes it where the load failed.
|
||||||
let track = MediaItem {
|
let track = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
item_type: Some("Audio".to_string()),
|
item_type: Some("Audio".to_string()),
|
||||||
..handoff_item()
|
..handoff_item()
|
||||||
};
|
};
|
||||||
@@ -318,8 +314,6 @@ mod tests {
|
|||||||
// An HLS playlist declares its segments, so a failed segment load is
|
// An HLS playlist declares its segments, so a failed segment load is
|
||||||
// retried at that segment, not at the start of the episode.
|
// retried at that segment, not at the start of the episode.
|
||||||
let video = MediaItem {
|
let video = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
media_type: MediaType::Video,
|
media_type: MediaType::Video,
|
||||||
..handoff_item()
|
..handoff_item()
|
||||||
};
|
};
|
||||||
@@ -330,8 +324,6 @@ mod tests {
|
|||||||
fn test_downloaded_episode_keeps_the_players_retry() {
|
fn test_downloaded_episode_keeps_the_players_retry() {
|
||||||
// A local file has no length problem and no network to lose.
|
// A local file has no length problem and no network to lose.
|
||||||
let local = MediaItem {
|
let local = MediaItem {
|
||||||
// Audio and direct-URL items never negotiate a transport.
|
|
||||||
transport: None,
|
|
||||||
source: MediaSource::Local {
|
source: MediaSource::Local {
|
||||||
file_path: PathBuf::from("/data/ep2.mkv"),
|
file_path: PathBuf::from("/data/ep2.mkv"),
|
||||||
jellyfin_item_id: Some("ep2".to_string()),
|
jellyfin_item_id: Some("ep2".to_string()),
|
||||||
|
|||||||
@@ -97,24 +97,6 @@ impl HybridRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide what stream to play and describe it fully — the DR-224 contract.
|
|
||||||
///
|
|
||||||
/// Online-only for the same reason as `get_video_stream_url`: an offline
|
|
||||||
/// item is a file on disk, and the caller builds
|
|
||||||
/// [`StreamSelection::local_file`] for it rather than negotiating anything.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227
|
|
||||||
pub async fn get_stream_selection(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: Option<&str>,
|
|
||||||
audio_stream_index: Option<i32>,
|
|
||||||
) -> Result<super::StreamSelection, RepoError> {
|
|
||||||
self.online
|
|
||||||
.get_stream_selection(item_id, media_source_id, audio_stream_index)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get an audio-only stream URL for a video item (background-audio handoff).
|
/// Get an audio-only stream URL for a video item (background-audio handoff).
|
||||||
/// Online-only, like `get_video_stream_url`.
|
/// Online-only, like `get_video_stream_url`.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ pub mod hybrid;
|
|||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod online;
|
pub mod online;
|
||||||
pub mod series_progress;
|
pub mod series_progress;
|
||||||
/// Backend-owned stream selection (UR-079 / DR-224).
|
|
||||||
pub mod stream_selection;
|
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use hybrid::HybridRepository;
|
pub use hybrid::HybridRepository;
|
||||||
pub use offline::OfflineRepository;
|
pub use offline::OfflineRepository;
|
||||||
pub use online::{JRayActor, OnlineRepository};
|
pub use online::{JRayActor, OnlineRepository};
|
||||||
pub use stream_selection::{StreamSelection, Transport};
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
+252
-771
File diff suppressed because it is too large
Load Diff
@@ -1,416 +0,0 @@
|
|||||||
//! What stream to play, decided in Rust and handed to a player whole.
|
|
||||||
//!
|
|
||||||
//! Every player backend — mpv, ExoPlayer, the webview `<video>`/hls.js path —
|
|
||||||
//! used to receive a bare URL and re-derive the rest: the frontend decided
|
|
||||||
//! "is this HLS?" by looking for `.m3u8` in the string, and nothing anywhere
|
|
||||||
//! carried *why* a stream was transcoded or what else the source could have
|
|
||||||
//! offered. This module is the replacement contract: one self-describing
|
|
||||||
//! [`StreamSelection`] that says what the stream is, how to fetch it, and what
|
|
||||||
//! the alternatives were.
|
|
||||||
//!
|
|
||||||
//! The division of labour it encodes — **Rust decides *what stream*, the player
|
|
||||||
//! decides *how to deliver it*** — is the point. A multi-variant playlist handed
|
|
||||||
//! to ExoPlayer is still ExoPlayer's to adapt over; Rust never paces bytes.
|
|
||||||
//!
|
|
||||||
//! TRACES: UR-079 | DR-224
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::settings::StreamingQuality;
|
|
||||||
|
|
||||||
/// How the bytes of a chosen stream are fetched.
|
|
||||||
///
|
|
||||||
/// This field exists to delete a substring search. The frontend previously
|
|
||||||
/// decided which loader to attach by testing `url.contains(".m3u8")`, which is a
|
|
||||||
/// domain fact reconstructed in the presentation layer — the same class of leak
|
|
||||||
/// as the item-type taxonomy that `check:boundary` guards, and one that breaks
|
|
||||||
/// silently the moment a server serves a playlist from a path that does not end
|
|
||||||
/// in `.m3u8`, or serves a progressive file from one that does.
|
|
||||||
///
|
|
||||||
/// Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
|
|
||||||
/// discriminant instead of comparing text.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224
|
|
||||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
pub enum Transport {
|
|
||||||
/// An HLS playlist. The webview attaches hls.js (or Safari's native loader);
|
|
||||||
/// ExoPlayer uses its HLS media source.
|
|
||||||
Hls,
|
|
||||||
/// A single progressive HTTP resource, seekable by byte range.
|
|
||||||
Progressive,
|
|
||||||
/// A file already on disk — a completed download, or the loopback media
|
|
||||||
/// server standing in front of one.
|
|
||||||
LocalFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the server is doing to the source to produce this stream.
|
|
||||||
///
|
|
||||||
/// Distinct from [`Transport`] because the two are genuinely independent: a
|
|
||||||
/// direct-streamed remux and a transcode can both arrive over HLS, and a direct
|
|
||||||
/// play can arrive progressively or as a local file. Keeping them apart is what
|
|
||||||
/// lets the UI say "this is not costing the server anything" without inferring
|
|
||||||
/// it from a URL shape.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-227
|
|
||||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
pub enum PlaybackKind {
|
|
||||||
/// The source file is served untouched. No server CPU, no quality loss.
|
|
||||||
DirectPlay,
|
|
||||||
/// The container is repackaged but the codecs are copied — cheap, and
|
|
||||||
/// visually identical to the source.
|
|
||||||
DirectStream,
|
|
||||||
/// The server is re-encoding. The only case where a bitrate ceiling can
|
|
||||||
/// actually be honoured, and the only one that costs the server real work.
|
|
||||||
Transcode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackKind {
|
|
||||||
/// Whether the server is spending encoder time on this stream.
|
|
||||||
///
|
|
||||||
/// The queue carries a `needs_transcoding` flag that predates this enum and
|
|
||||||
/// that several seek/reload paths still branch on; this keeps the two from
|
|
||||||
/// drifting by making one derive from the other.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-227
|
|
||||||
pub fn needs_transcoding(&self) -> bool {
|
|
||||||
matches!(self, PlaybackKind::Transcode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The rendition actually negotiated — what the viewer is receiving right now.
|
|
||||||
///
|
|
||||||
/// `None` on a [`StreamSelection`] when the source is being direct-played as-is:
|
|
||||||
/// there is no *chosen* rendition in that case, only the file itself, and
|
|
||||||
/// reporting the ceiling that happened to be set would misdescribe it.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224, DR-225
|
|
||||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Rendition {
|
|
||||||
/// The rung of the ladder this stream was built against.
|
|
||||||
pub quality: StreamingQuality,
|
|
||||||
/// Total bits per second the stream may use, when a ceiling applies.
|
|
||||||
pub max_bitrate: Option<u64>,
|
|
||||||
/// Resolution ceiling, when one applies. `None` preserves the source's.
|
|
||||||
pub max_height: Option<u32>,
|
|
||||||
/// Video codec the server was asked to produce.
|
|
||||||
pub video_codec: Option<String>,
|
|
||||||
/// Audio codec the server was asked to produce.
|
|
||||||
pub audio_codec: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One rung of the quality picker, as it applies to *this* media source.
|
|
||||||
///
|
|
||||||
/// The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
|
|
||||||
/// which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
|
|
||||||
/// them indistinguishable from Original. `exceeds_source` is what lets the
|
|
||||||
/// frontend render that honestly without knowing anything about bitrates.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226, DR-121
|
|
||||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct QualityOption {
|
|
||||||
pub quality: StreamingQuality,
|
|
||||||
/// Human label ("8 Mbps"). Lives in Rust beside the number it describes.
|
|
||||||
pub label: String,
|
|
||||||
/// Secondary line ("1080p").
|
|
||||||
pub detail: String,
|
|
||||||
/// True when this rung's ceiling is at or above what the source itself
|
|
||||||
/// carries, so selecting it yields the same stream as `Original`.
|
|
||||||
///
|
|
||||||
/// The frontend renders these differently (or hides them); it does not
|
|
||||||
/// decide which they are.
|
|
||||||
pub exceeds_source: bool,
|
|
||||||
/// The source's own bitrate, when the server reported one. Presentation
|
|
||||||
/// only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
|
|
||||||
pub source_bitrate: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything a player backend needs to open a stream, and everything the UI
|
|
||||||
/// needs to describe it.
|
|
||||||
///
|
|
||||||
/// Replaces the bare `String` URL that `get_video_stream_url` used to return.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224, DR-226, DR-227
|
|
||||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StreamSelection {
|
|
||||||
/// The URL (or loopback URL) to open.
|
|
||||||
pub url: String,
|
|
||||||
/// How to fetch it. Replaces the `.m3u8` substring check.
|
|
||||||
pub transport: Transport,
|
|
||||||
/// What the server is doing to the source to produce it.
|
|
||||||
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, for the quality picker (DR-226).
|
|
||||||
pub available: Vec<QualityOption>,
|
|
||||||
/// The media source this selection is for, so a later re-open (quality
|
|
||||||
/// change, audio-track switch, transcoded seek) targets the same one.
|
|
||||||
pub media_source_id: Option<String>,
|
|
||||||
/// The transcode identity the server keyed this job by, when there is one.
|
|
||||||
pub play_session_id: Option<String>,
|
|
||||||
/// Whether the server is spending encoder time on this stream.
|
|
||||||
///
|
|
||||||
/// Derived from [`playback_kind`](Self::playback_kind) rather than left for
|
|
||||||
/// the frontend to compute: "which kinds count as transcoding" is a domain
|
|
||||||
/// rule, and a direct *stream* is a remux that must not be counted. The
|
|
||||||
/// queue's long-standing `needs_transcoding` flag and the seek strategy both
|
|
||||||
/// read this, so there is one answer rather than three.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224, DR-227
|
|
||||||
pub needs_transcoding: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StreamSelection {
|
|
||||||
/// A selection for a file already on disk.
|
|
||||||
///
|
|
||||||
/// A downloaded file is a direct play by definition — the bytes are the
|
|
||||||
/// source's — and offering a quality ladder over it would be a lie, since
|
|
||||||
/// nothing about a local file can be re-negotiated.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-071, UR-079 | DR-224
|
|
||||||
pub fn local_file(url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
url: url.into(),
|
|
||||||
transport: Transport::LocalFile,
|
|
||||||
playback_kind: PlaybackKind::DirectPlay,
|
|
||||||
rendition: None,
|
|
||||||
available: Vec::new(),
|
|
||||||
media_source_id: None,
|
|
||||||
play_session_id: None,
|
|
||||||
needs_transcoding: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the quality ladder as it applies to a source of a known bitrate.
|
|
||||||
///
|
|
||||||
/// Every rung is returned — the picker stays a fixed, predictable list rather
|
|
||||||
/// than one that changes length per item — but each is marked with whether it
|
|
||||||
/// would actually constrain *this* source. A rung whose ceiling is at or above
|
|
||||||
/// the source bitrate produces the same bytes as `Original`, so presenting it as
|
|
||||||
/// a distinct choice is noise.
|
|
||||||
///
|
|
||||||
/// `source_bitrate` is `None` when the server did not report one (it is absent
|
|
||||||
/// for some containers — the sampled library has `avi` files with no bitrate at
|
|
||||||
/// all). In that case nothing can be judged redundant and every rung is offered,
|
|
||||||
/// which is the safe direction: the viewer keeps every choice they had before.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226, DR-121 | UT-211
|
|
||||||
pub fn quality_options_for_source(source_bitrate: Option<u64>) -> Vec<QualityOption> {
|
|
||||||
StreamingQuality::ALL
|
|
||||||
.iter()
|
|
||||||
.map(|quality| QualityOption {
|
|
||||||
quality: *quality,
|
|
||||||
label: quality.label().to_string(),
|
|
||||||
detail: quality.detail().to_string(),
|
|
||||||
exceeds_source: match (quality.max_bitrate(), source_bitrate) {
|
|
||||||
// `Original` is the source; it never "exceeds" it.
|
|
||||||
(None, _) => false,
|
|
||||||
// Nothing known about the source — judge nothing redundant.
|
|
||||||
(Some(_), None) => false,
|
|
||||||
(Some(cap), Some(source)) => cap >= source,
|
|
||||||
},
|
|
||||||
source_bitrate,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// The tag the frontend matches on has to be exactly what it expects, and
|
|
||||||
/// it is a *string in TypeScript* — nothing but a test keeps the two in step.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_transport_serialises_with_the_tag_the_frontend_matches() {
|
|
||||||
let cases = [
|
|
||||||
(Transport::Hls, r#"{"type":"hls"}"#),
|
|
||||||
(Transport::Progressive, r#"{"type":"progressive"}"#),
|
|
||||||
(Transport::LocalFile, r#"{"type":"localFile"}"#),
|
|
||||||
];
|
|
||||||
for (transport, expected) in cases {
|
|
||||||
let json = serde_json::to_string(&transport).expect("serialises");
|
|
||||||
assert_eq!(json, expected, "wire shape of {transport:?}");
|
|
||||||
let back: Transport = serde_json::from_str(&json).expect("round-trips");
|
|
||||||
assert_eq!(back, transport);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TRACES: UR-079 | DR-227 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_playback_kind_serialises_with_the_tag_the_frontend_matches() {
|
|
||||||
let cases = [
|
|
||||||
(PlaybackKind::DirectPlay, r#"{"type":"directPlay"}"#),
|
|
||||||
(PlaybackKind::DirectStream, r#"{"type":"directStream"}"#),
|
|
||||||
(PlaybackKind::Transcode, r#"{"type":"transcode"}"#),
|
|
||||||
];
|
|
||||||
for (kind, expected) in cases {
|
|
||||||
let json = serde_json::to_string(&kind).expect("serialises");
|
|
||||||
assert_eq!(json, expected, "wire shape of {kind:?}");
|
|
||||||
let back: PlaybackKind = serde_json::from_str(&json).expect("round-trips");
|
|
||||||
assert_eq!(back, kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Only a transcode costs the server encoder time. A direct *stream* is a
|
|
||||||
/// remux — cheap, and not what `needs_transcoding` has ever meant.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-227 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_only_transcode_counts_as_transcoding() {
|
|
||||||
assert!(PlaybackKind::Transcode.needs_transcoding());
|
|
||||||
assert!(!PlaybackKind::DirectStream.needs_transcoding());
|
|
||||||
assert!(!PlaybackKind::DirectPlay.needs_transcoding());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A local file is a direct play over a local transport, with no ladder:
|
|
||||||
/// nothing about a file on disk can be re-negotiated.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-071, UR-079 | DR-224 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_local_file_selection_offers_no_ladder() {
|
|
||||||
let selection = StreamSelection::local_file("http://127.0.0.1:9000/media/x.mkv");
|
|
||||||
assert_eq!(selection.transport, Transport::LocalFile);
|
|
||||||
assert_eq!(selection.playback_kind, PlaybackKind::DirectPlay);
|
|
||||||
assert!(selection.rendition.is_none());
|
|
||||||
assert!(selection.available.is_empty());
|
|
||||||
assert!(!selection.needs_transcoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The camelCase rule applies to nested struct fields too, and
|
|
||||||
/// `playbackKind` is the one the frontend branches on.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_stream_selection_fields_are_camel_case_on_the_wire() {
|
|
||||||
let selection = StreamSelection {
|
|
||||||
url: "https://example/master.m3u8".to_string(),
|
|
||||||
transport: Transport::Hls,
|
|
||||||
playback_kind: PlaybackKind::Transcode,
|
|
||||||
rendition: Some(Rendition {
|
|
||||||
quality: StreamingQuality::Mbps8,
|
|
||||||
max_bitrate: Some(8_000_000),
|
|
||||||
max_height: Some(1080),
|
|
||||||
video_codec: Some("h264".to_string()),
|
|
||||||
audio_codec: Some("aac".to_string()),
|
|
||||||
}),
|
|
||||||
available: Vec::new(),
|
|
||||||
media_source_id: Some("src-1".to_string()),
|
|
||||||
play_session_id: Some("sess-1".to_string()),
|
|
||||||
needs_transcoding: true,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&selection).expect("serialises");
|
|
||||||
assert!(
|
|
||||||
json.contains(r#""playbackKind":{"type":"transcode"}"#),
|
|
||||||
"{json}"
|
|
||||||
);
|
|
||||||
assert!(json.contains(r#""transport":{"type":"hls"}"#), "{json}");
|
|
||||||
assert!(json.contains(r#""mediaSourceId":"src-1""#), "{json}");
|
|
||||||
assert!(json.contains(r#""playSessionId":"sess-1""#), "{json}");
|
|
||||||
assert!(json.contains(r#""maxBitrate":8000000"#), "{json}");
|
|
||||||
assert!(json.contains(r#""maxHeight":1080"#), "{json}");
|
|
||||||
assert!(json.contains(r#""needsTranscoding":true"#), "{json}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The measured library has 1.1 Mbps sources in it. Offering those a choice
|
|
||||||
/// of 20, 10, 8, 4 and 2 Mbps is offering five ways to spell "Original".
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226, DR-121 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_rungs_above_the_source_bitrate_are_marked_redundant() {
|
|
||||||
let options = quality_options_for_source(Some(1_122_137));
|
|
||||||
let redundant: Vec<_> = options
|
|
||||||
.iter()
|
|
||||||
.filter(|o| o.exceeds_source)
|
|
||||||
.map(|o| o.quality)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
redundant,
|
|
||||||
vec![
|
|
||||||
StreamingQuality::Mbps20,
|
|
||||||
StreamingQuality::Mbps10,
|
|
||||||
StreamingQuality::Mbps8,
|
|
||||||
StreamingQuality::Mbps4,
|
|
||||||
StreamingQuality::Mbps2,
|
|
||||||
],
|
|
||||||
"every rung at or above a 1.12 Mbps source is the source"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The rungs that genuinely constrain it are not marked.
|
|
||||||
let constraining: Vec<_> = options
|
|
||||||
.iter()
|
|
||||||
.filter(|o| !o.exceeds_source)
|
|
||||||
.map(|o| o.quality)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
constraining,
|
|
||||||
vec![
|
|
||||||
StreamingQuality::Original,
|
|
||||||
StreamingQuality::Mbps1,
|
|
||||||
StreamingQuality::Kbps720,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Original` is the source, so it is never "above" it — not even for a
|
|
||||||
/// source whose bitrate is unknown or zero.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_original_is_never_marked_as_exceeding_the_source() {
|
|
||||||
for bitrate in [None, Some(0), Some(1), Some(50_000_000)] {
|
|
||||||
let options = quality_options_for_source(bitrate);
|
|
||||||
let original = options
|
|
||||||
.iter()
|
|
||||||
.find(|o| o.quality == StreamingQuality::Original)
|
|
||||||
.expect("Original is always offered");
|
|
||||||
assert!(!original.exceeds_source, "bitrate {bitrate:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An `avi` with no reported bitrate must not lose the picker. Judging
|
|
||||||
/// nothing redundant is the safe direction — the viewer keeps every choice.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_an_unknown_source_bitrate_keeps_every_rung_offered() {
|
|
||||||
let options = quality_options_for_source(None);
|
|
||||||
assert_eq!(options.len(), StreamingQuality::ALL.len());
|
|
||||||
assert!(
|
|
||||||
options.iter().all(|o| !o.exceeds_source),
|
|
||||||
"nothing can be judged redundant without a source bitrate"
|
|
||||||
);
|
|
||||||
assert!(options.iter().all(|o| o.source_bitrate.is_none()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A 4K remux constrains at every rung — the ladder is fully meaningful.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_a_source_above_the_ladder_marks_nothing_redundant() {
|
|
||||||
let options = quality_options_for_source(Some(40_000_000));
|
|
||||||
assert!(options.iter().all(|o| !o.exceeds_source));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The picker's text comes from Rust, beside the numbers it describes, so a
|
|
||||||
/// relabelled rung cannot drift out of step with what it does.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-070, UR-079 | DR-226 | UT-211
|
|
||||||
#[test]
|
|
||||||
fn test_options_carry_the_ladder_labels() {
|
|
||||||
let options = quality_options_for_source(Some(6_652_961));
|
|
||||||
assert_eq!(options.len(), StreamingQuality::ALL.len());
|
|
||||||
for (option, quality) in options.iter().zip(StreamingQuality::ALL) {
|
|
||||||
assert_eq!(option.quality, quality);
|
|
||||||
assert_eq!(option.label, quality.label());
|
|
||||||
assert_eq!(option.detail, quality.detail());
|
|
||||||
assert_eq!(option.source_bitrate, Some(6_652_961));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -469,15 +469,6 @@ pub struct LiveStreamInfo {
|
|||||||
pub play_session_id: Option<String>,
|
pub play_session_id: Option<String>,
|
||||||
pub live_stream_id: Option<String>,
|
pub live_stream_id: Option<String>,
|
||||||
pub media_source_id: Option<String>,
|
pub media_source_id: Option<String>,
|
||||||
/// How to open `stream_url`.
|
|
||||||
///
|
|
||||||
/// A live channel is always an HLS transcode — the server has to repackage a
|
|
||||||
/// broadcast mux into something a browser can play, and there is no static
|
|
||||||
/// file to direct-play. Saying so here means the player page never has to
|
|
||||||
/// work it out from the URL, which is the whole of DR-224.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-079 | DR-224
|
|
||||||
pub transport: super::stream_selection::Transport,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genre
|
/// Genre
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.10.0",
|
"version": "0.9.1",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+12
-253
@@ -102,7 +102,7 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
|
|||||||
*
|
*
|
||||||
* This command analyzes the current video stream and automatically chooses
|
* This command analyzes the current video stream and automatically chooses
|
||||||
* the best seeking strategy:
|
* the best seeking strategy:
|
||||||
* - HLS streams: Use native seeking
|
* - HLS streams (.m3u8): Use native seeking
|
||||||
* - Direct play streams: Use native seeking
|
* - Direct play streams: Use native seeking
|
||||||
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||||
*
|
*
|
||||||
@@ -245,17 +245,13 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
|
|||||||
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||||
* native backend is reloaded here.
|
* native backend is reloaded here.
|
||||||
*
|
*
|
||||||
* The change applies to **this playback only**. The in-player picker is a
|
* The change applies to this playback *and* to everything started afterwards
|
||||||
* "this film, this connection" control and its doc has always said so, but it
|
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||||
* used to be implemented by writing the process-wide ceiling — so choosing
|
* the in-player picker is a "this film, this connection" control, and the
|
||||||
* 2 Mbps to get one awkward film moving silently capped every video played
|
* durable default belongs to Settings. `player_set_video_settings` is the one
|
||||||
* afterwards for the rest of the process, with the Settings screen still
|
* that writes to the database.
|
||||||
* showing the old value and nothing in the UI admitting the change. It now
|
|
||||||
* sets a per-playback override that the next item clears; the durable default
|
|
||||||
* belongs to Settings, and `player_set_video_settings` is the one that writes
|
|
||||||
* to the database.
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-074, UR-079 | DR-162, DR-225
|
* TRACES: UR-074 | DR-162
|
||||||
*/
|
*/
|
||||||
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||||
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
||||||
@@ -1019,22 +1015,6 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
|
|||||||
async mediaLocalUrl(path: string) : Promise<string> {
|
async mediaLocalUrl(path: string) : Promise<string> {
|
||||||
return await TAURI_INVOKE("media_local_url", { path });
|
return await TAURI_INVOKE("media_local_url", { path });
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* The stream selection for a downloaded file.
|
|
||||||
*
|
|
||||||
* The local-playback counterpart to `repository_get_stream_selection`. A file
|
|
||||||
* on disk needs no negotiation — it is a direct play over a local transport,
|
|
||||||
* with no quality ladder, because nothing about it can be re-negotiated — but
|
|
||||||
* the *frontend must not be the one to say so*. It gets the same
|
|
||||||
* [`StreamSelection`] shape as a streamed source so the player has one contract
|
|
||||||
* to consume rather than two, and so no caller has to infer a transport from a
|
|
||||||
* loopback URL.
|
|
||||||
*
|
|
||||||
* TRACES: UR-071, UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
async mediaLocalSelection(path: string) : Promise<StreamSelection> {
|
|
||||||
return await TAURI_INVOKE("media_local_selection", { path });
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* Start downloading a file immediately
|
* Start downloading a file immediately
|
||||||
* This command actually downloads the file using the worker
|
* This command actually downloads the file using the worker
|
||||||
@@ -1620,24 +1600,6 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
|
|||||||
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
|
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
|
||||||
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
|
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Decide what stream to play for a video, and describe it.
|
|
||||||
*
|
|
||||||
* Replaces `repository_get_video_stream_url` for playback. The returned
|
|
||||||
* [`StreamSelection`] carries the transport explicitly, so the frontend picks
|
|
||||||
* its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
|
|
||||||
* carries the quality ladder as it applies to *this* source, so the picker can
|
|
||||||
* stop offering rungs that produce the same bytes as Original.
|
|
||||||
*
|
|
||||||
* No start-position parameter, for the same reason as the URL builder: a
|
|
||||||
* position on an HLS playlist is copied onto every segment URI and the server
|
|
||||||
* rejects each with `400` (DR-181). Callers resume by seeking after load.
|
|
||||||
*
|
|
||||||
* TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227 | UT-212
|
|
||||||
*/
|
|
||||||
async repositoryGetStreamSelection(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamSelection> {
|
|
||||||
return await TAURI_INVOKE("repository_get_stream_selection", { handle, itemId, mediaSourceId, audioStreamIndex });
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* Get audio stream URL for a track
|
* Get audio stream URL for a track
|
||||||
*/
|
*/
|
||||||
@@ -1985,7 +1947,7 @@ export type AudioTrackSwitchResponse =
|
|||||||
/**
|
/**
|
||||||
* HTML5 needs to reload stream with new audio track
|
* HTML5 needs to reload stream with new audio track
|
||||||
*/
|
*/
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||||
/**
|
/**
|
||||||
* Authentication result
|
* Authentication result
|
||||||
*/
|
*/
|
||||||
@@ -2332,18 +2294,7 @@ excludedItemIds?: string[] }
|
|||||||
* streamed; the server returns a transcoding URL (already absolute) plus a
|
* streamed; the server returns a transcoding URL (already absolute) plus a
|
||||||
* `live_stream_id` that can later be used to close the stream.
|
* `live_stream_id` that can later be used to close the stream.
|
||||||
*/
|
*/
|
||||||
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null;
|
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
|
||||||
/**
|
|
||||||
* How to open `stream_url`.
|
|
||||||
*
|
|
||||||
* A live channel is always an HLS transcode — the server has to repackage a
|
|
||||||
* broadcast mux into something a browser can play, and there is no static
|
|
||||||
* file to direct-play. Saying so here means the player page never has to
|
|
||||||
* work it out from the URL, which is the whole of DR-224.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
transport: Transport }
|
|
||||||
/**
|
/**
|
||||||
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
||||||
*
|
*
|
||||||
@@ -2582,18 +2533,6 @@ videoCodec: string;
|
|||||||
* Whether the video requires server-side transcoding
|
* Whether the video requires server-side transcoding
|
||||||
*/
|
*/
|
||||||
needsTranscoding: boolean;
|
needsTranscoding: boolean;
|
||||||
/**
|
|
||||||
* How this item's stream is fetched, as the backend decided it.
|
|
||||||
*
|
|
||||||
* Carried on the queue item so a later seek/reload does not have to guess.
|
|
||||||
* `None` for items queued by a path that never negotiated (audio tracks,
|
|
||||||
* direct URLs) and for anything queued before this field existed, where the
|
|
||||||
* caller falls back to `needs_transcoding` — every transcode this app
|
|
||||||
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
|
||||||
*
|
|
||||||
* TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
|
|
||||||
*/
|
|
||||||
transport?: Transport | null;
|
|
||||||
/**
|
/**
|
||||||
* Optional now-playing metadata. Used by the background-audio handoff so the
|
* Optional now-playing metadata. Used by the background-audio handoff so the
|
||||||
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||||
@@ -2706,32 +2645,6 @@ supportsNativeVideo: boolean }
|
|||||||
* Playback information
|
* Playback information
|
||||||
*/
|
*/
|
||||||
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
|
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
|
||||||
/**
|
|
||||||
* What the server is doing to the source to produce this stream.
|
|
||||||
*
|
|
||||||
* Distinct from [`Transport`] because the two are genuinely independent: a
|
|
||||||
* direct-streamed remux and a transcode can both arrive over HLS, and a direct
|
|
||||||
* play can arrive progressively or as a local file. Keeping them apart is what
|
|
||||||
* lets the UI say "this is not costing the server anything" without inferring
|
|
||||||
* it from a URL shape.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-227
|
|
||||||
*/
|
|
||||||
export type PlaybackKind =
|
|
||||||
/**
|
|
||||||
* The source file is served untouched. No server CPU, no quality loss.
|
|
||||||
*/
|
|
||||||
{ type: "directPlay" } |
|
|
||||||
/**
|
|
||||||
* The container is repackaged but the codecs are copied — cheap, and
|
|
||||||
* visually identical to the source.
|
|
||||||
*/
|
|
||||||
{ type: "directStream" } |
|
|
||||||
/**
|
|
||||||
* The server is re-encoding. The only case where a bitrate ceiling can
|
|
||||||
* actually be honoured, and the only one that costs the server real work.
|
|
||||||
*/
|
|
||||||
{ type: "transcode" }
|
|
||||||
/**
|
/**
|
||||||
* Playback mode - local device, remote session, or idle
|
* Playback mode - local device, remote session, or idle
|
||||||
*/
|
*/
|
||||||
@@ -2831,18 +2744,6 @@ videoCodec?: string | null;
|
|||||||
* Whether the video requires server-side transcoding
|
* Whether the video requires server-side transcoding
|
||||||
*/
|
*/
|
||||||
needsTranscoding?: boolean;
|
needsTranscoding?: boolean;
|
||||||
/**
|
|
||||||
* How this item's stream is fetched, as the backend decided it.
|
|
||||||
*
|
|
||||||
* Carried on the queue item so a later seek/reload does not have to guess.
|
|
||||||
* `None` for items queued by a path that never negotiated (audio tracks,
|
|
||||||
* direct URLs) and for anything queued before this field existed, where the
|
|
||||||
* caller falls back to `needs_transcoding` — every transcode this app
|
|
||||||
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
|
||||||
*
|
|
||||||
* TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
|
|
||||||
*/
|
|
||||||
transport?: Transport | null;
|
|
||||||
/**
|
/**
|
||||||
* Video width in pixels
|
* Video width in pixels
|
||||||
*/
|
*/
|
||||||
@@ -3125,38 +3026,6 @@ alreadyDownloaded: number;
|
|||||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||||
*/
|
*/
|
||||||
skipped: number }
|
skipped: number }
|
||||||
/**
|
|
||||||
* One rung of the quality picker, as it applies to *this* media source.
|
|
||||||
*
|
|
||||||
* The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
|
|
||||||
* which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
|
|
||||||
* them indistinguishable from Original. `exceeds_source` is what lets the
|
|
||||||
* frontend render that honestly without knowing anything about bitrates.
|
|
||||||
*
|
|
||||||
* TRACES: UR-070, UR-079 | DR-226, DR-121
|
|
||||||
*/
|
|
||||||
export type QualityOption = { quality: StreamingQuality;
|
|
||||||
/**
|
|
||||||
* Human label ("8 Mbps"). Lives in Rust beside the number it describes.
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Secondary line ("1080p").
|
|
||||||
*/
|
|
||||||
detail: string;
|
|
||||||
/**
|
|
||||||
* True when this rung's ceiling is at or above what the source itself
|
|
||||||
* carries, so selecting it yields the same stream as `Original`.
|
|
||||||
*
|
|
||||||
* The frontend renders these differently (or hides them); it does not
|
|
||||||
* decide which they are.
|
|
||||||
*/
|
|
||||||
exceedsSource: boolean;
|
|
||||||
/**
|
|
||||||
* The source's own bitrate, when the server reported one. Presentation
|
|
||||||
* only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
|
|
||||||
*/
|
|
||||||
sourceBitrate: number | null }
|
|
||||||
/**
|
/**
|
||||||
* Response for queue queries
|
* Response for queue queries
|
||||||
*/
|
*/
|
||||||
@@ -3165,36 +3034,6 @@ export type QueueStatus = { items: PlayerMediaItem[]; currentIndex: number | nul
|
|||||||
* Remote session status for UI updates
|
* Remote session status for UI updates
|
||||||
*/
|
*/
|
||||||
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
|
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
|
||||||
/**
|
|
||||||
* The rendition actually negotiated — what the viewer is receiving right now.
|
|
||||||
*
|
|
||||||
* `None` on a [`StreamSelection`] when the source is being direct-played as-is:
|
|
||||||
* there is no *chosen* rendition in that case, only the file itself, and
|
|
||||||
* reporting the ceiling that happened to be set would misdescribe it.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224, DR-225
|
|
||||||
*/
|
|
||||||
export type Rendition = {
|
|
||||||
/**
|
|
||||||
* The rung of the ladder this stream was built against.
|
|
||||||
*/
|
|
||||||
quality: StreamingQuality;
|
|
||||||
/**
|
|
||||||
* Total bits per second the stream may use, when a ceiling applies.
|
|
||||||
*/
|
|
||||||
maxBitrate: number | null;
|
|
||||||
/**
|
|
||||||
* Resolution ceiling, when one applies. `None` preserves the source's.
|
|
||||||
*/
|
|
||||||
maxHeight: number | null;
|
|
||||||
/**
|
|
||||||
* Video codec the server was asked to produce.
|
|
||||||
*/
|
|
||||||
videoCodec: string | null;
|
|
||||||
/**
|
|
||||||
* Audio codec the server was asked to produce.
|
|
||||||
*/
|
|
||||||
audioCodec: string | null }
|
|
||||||
/**
|
/**
|
||||||
* Repeat mode for the queue
|
* Repeat mode for the queue
|
||||||
*
|
*
|
||||||
@@ -3312,59 +3151,9 @@ export type StreamQualityResponse =
|
|||||||
*/
|
*/
|
||||||
{ strategy: "native"; position: number } |
|
{ strategy: "native"; position: number } |
|
||||||
/**
|
/**
|
||||||
* HTML5 must reload its element with this selection.
|
* HTML5 must reload its element with this URL.
|
||||||
*/
|
*/
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||||
/**
|
|
||||||
* Everything a player backend needs to open a stream, and everything the UI
|
|
||||||
* needs to describe it.
|
|
||||||
*
|
|
||||||
* Replaces the bare `String` URL that `get_video_stream_url` used to return.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224, DR-226, DR-227
|
|
||||||
*/
|
|
||||||
export type StreamSelection = {
|
|
||||||
/**
|
|
||||||
* The URL (or loopback URL) to open.
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* How to fetch it. Replaces the `.m3u8` substring check.
|
|
||||||
*/
|
|
||||||
transport: Transport;
|
|
||||||
/**
|
|
||||||
* What the server is doing to the source to produce it.
|
|
||||||
*/
|
|
||||||
playbackKind: PlaybackKind;
|
|
||||||
/**
|
|
||||||
* The negotiated rendition; `None` when direct-playing the source as-is.
|
|
||||||
*/
|
|
||||||
rendition: Rendition | null;
|
|
||||||
/**
|
|
||||||
* What this media source can offer, for the quality picker (DR-226).
|
|
||||||
*/
|
|
||||||
available: QualityOption[];
|
|
||||||
/**
|
|
||||||
* The media source this selection is for, so a later re-open (quality
|
|
||||||
* change, audio-track switch, transcoded seek) targets the same one.
|
|
||||||
*/
|
|
||||||
mediaSourceId: string | null;
|
|
||||||
/**
|
|
||||||
* The transcode identity the server keyed this job by, when there is one.
|
|
||||||
*/
|
|
||||||
playSessionId: string | null;
|
|
||||||
/**
|
|
||||||
* Whether the server is spending encoder time on this stream.
|
|
||||||
*
|
|
||||||
* Derived from [`playback_kind`](Self::playback_kind) rather than left for
|
|
||||||
* the frontend to compute: "which kinds count as transcoding" is a domain
|
|
||||||
* rule, and a direct *stream* is a remux that must not be counted. The
|
|
||||||
* queue's long-standing `needs_transcoding` flag and the seek strategy both
|
|
||||||
* read this, so there is one answer rather than three.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224, DR-227
|
|
||||||
*/
|
|
||||||
needsTranscoding: boolean }
|
|
||||||
/**
|
/**
|
||||||
* A ceiling on how much bandwidth a *video* stream may consume.
|
* A ceiling on how much bandwidth a *video* stream may consume.
|
||||||
*
|
*
|
||||||
@@ -3445,36 +3234,6 @@ itemName: string | null }
|
|||||||
* Statistics about the thumbnail cache
|
* Statistics about the thumbnail cache
|
||||||
*/
|
*/
|
||||||
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
|
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
|
||||||
/**
|
|
||||||
* How the bytes of a chosen stream are fetched.
|
|
||||||
*
|
|
||||||
* This field exists to delete a substring search. The frontend previously
|
|
||||||
* decided which loader to attach by testing `url.contains(".m3u8")`, which is a
|
|
||||||
* domain fact reconstructed in the presentation layer — the same class of leak
|
|
||||||
* as the item-type taxonomy that `check:boundary` guards, and one that breaks
|
|
||||||
* silently the moment a server serves a playlist from a path that does not end
|
|
||||||
* in `.m3u8`, or serves a progressive file from one that does.
|
|
||||||
*
|
|
||||||
* Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
|
|
||||||
* discriminant instead of comparing text.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
export type Transport =
|
|
||||||
/**
|
|
||||||
* An HLS playlist. The webview attaches hls.js (or Safari's native loader);
|
|
||||||
* ExoPlayer uses its HLS media source.
|
|
||||||
*/
|
|
||||||
{ type: "hls" } |
|
|
||||||
/**
|
|
||||||
* A single progressive HTTP resource, seekable by byte range.
|
|
||||||
*/
|
|
||||||
{ type: "progressive" } |
|
|
||||||
/**
|
|
||||||
* A file already on disk — a completed download, or the loopback media
|
|
||||||
* server standing in front of one.
|
|
||||||
*/
|
|
||||||
{ type: "localFile" }
|
|
||||||
/**
|
/**
|
||||||
* User information
|
* User information
|
||||||
*/
|
*/
|
||||||
@@ -3522,7 +3281,7 @@ export type VideoSeekResponse =
|
|||||||
/**
|
/**
|
||||||
* Reload stream from new position (transcoded non-HLS)
|
* Reload stream from new position (transcoded non-HLS)
|
||||||
*/
|
*/
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
|
{ strategy: "reloadStream"; new_url: string; seek_offset: number }
|
||||||
/**
|
/**
|
||||||
* Video playback settings
|
* Video playback settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// NO direct HTTP calls - everything routes through Rust backend
|
// NO direct HTTP calls - everything routes through Rust backend
|
||||||
|
|
||||||
import { commands } from "./bindings";
|
import { commands } from "./bindings";
|
||||||
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
|
||||||
import type { QualityPreset } from "./quality-presets";
|
import type { QualityPreset } from "./quality-presets";
|
||||||
import type {
|
import type {
|
||||||
Library,
|
Library,
|
||||||
@@ -247,34 +247,6 @@ export class RepositoryClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide what stream to play, and describe it.
|
|
||||||
*
|
|
||||||
* The playback counterpart to {@link getVideoStreamUrl}, which returns only a
|
|
||||||
* URL and therefore forces its caller to work out the rest. This returns the
|
|
||||||
* transport (so the player picks a loader from a tagged enum rather than by
|
|
||||||
* searching the URL for `.m3u8`), the playback kind (direct play / direct
|
|
||||||
* stream / transcode), and the quality ladder as it applies to this source.
|
|
||||||
*
|
|
||||||
* No position parameter, for the same reason as {@link getVideoStreamUrl}: a
|
|
||||||
* start position on an HLS playlist makes Jellyfin reject every segment behind
|
|
||||||
* it with `400` (DR-181). Resume by seeking once loaded.
|
|
||||||
*
|
|
||||||
* TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227 | UT-212
|
|
||||||
*/
|
|
||||||
async getStreamSelection(
|
|
||||||
itemId: string,
|
|
||||||
mediaSourceId?: string | null,
|
|
||||||
audioStreamIndex?: number | null,
|
|
||||||
): Promise<StreamSelection> {
|
|
||||||
return commands.repositoryGetStreamSelection(
|
|
||||||
this.ensureHandle(),
|
|
||||||
itemId,
|
|
||||||
mediaSourceId ?? null,
|
|
||||||
audioStreamIndex ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio-only stream URL for a video item, for the background-audio handoff.
|
* Audio-only stream URL for a video item, for the background-audio handoff.
|
||||||
* The server extracts just the audio track — no video is decoded on-device.
|
* The server extracts just the audio track — no video is decoded on-device.
|
||||||
|
|||||||
@@ -123,23 +123,6 @@ import VideoPlayer from "./VideoPlayer.svelte";
|
|||||||
import { player } from "$lib/stores/player";
|
import { player } from "$lib/stores/player";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEpisode(): MediaItem {
|
function makeEpisode(): MediaItem {
|
||||||
return {
|
return {
|
||||||
id: "ep1",
|
id: "ep1",
|
||||||
@@ -153,7 +136,7 @@ async function mountNativePlayer() {
|
|||||||
const utils = render(VideoPlayer, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
media: makeEpisode(),
|
||||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
mediaSourceId: "src-1",
|
mediaSourceId: "src-1",
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
@@ -278,7 +261,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
|||||||
const utils = render(VideoPlayer, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
media: makeEpisode(),
|
||||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
mediaSourceId: "src-1",
|
mediaSourceId: "src-1",
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
@@ -320,7 +303,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
|||||||
const utils = render(VideoPlayer, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
media: makeEpisode(),
|
||||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
mediaSourceId: "src-1",
|
mediaSourceId: "src-1",
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
|
|||||||
@@ -118,23 +118,6 @@ import VideoPlayer from "./VideoPlayer.svelte";
|
|||||||
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEpisode(): MediaItem {
|
function makeEpisode(): MediaItem {
|
||||||
return {
|
return {
|
||||||
id: "ep1",
|
id: "ep1",
|
||||||
@@ -156,7 +139,7 @@ async function mountAndroidPlayer() {
|
|||||||
const utils = render(VideoPlayer, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
media: makeEpisode(),
|
||||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
mediaSourceId: "src-1",
|
mediaSourceId: "src-1",
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type { JRayActor, StreamingQuality, StreamSelection } from "$lib/api/bindings";
|
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
@@ -76,21 +76,12 @@
|
|||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
import { elementSrcFor, videoLoaderFor } from "$lib/player/streamTransport";
|
|
||||||
|
|
||||||
const log = createLogger("VideoPlayer");
|
const log = createLogger("VideoPlayer");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
/**
|
streamUrl: string;
|
||||||
* What to play, as the backend decided it: URL, transport, playback kind and
|
|
||||||
* the quality ladder for this source. Replaces the bare `streamUrl` string,
|
|
||||||
* which forced this component to re-derive the transport by searching for
|
|
||||||
* `.m3u8`.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224, DR-226
|
|
||||||
*/
|
|
||||||
selection: StreamSelection;
|
|
||||||
mediaSourceId?: string; // Media source ID for subtitle URLs
|
mediaSourceId?: string; // Media source ID for subtitle URLs
|
||||||
initialPosition?: number; // Position in seconds to seek to after load (for resume)
|
initialPosition?: number; // Position in seconds to seek to after load (for resume)
|
||||||
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
|
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
|
||||||
@@ -111,7 +102,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
media,
|
media,
|
||||||
selection,
|
streamUrl,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
initialPosition,
|
initialPosition,
|
||||||
needsTranscoding = false,
|
needsTranscoding = false,
|
||||||
@@ -187,12 +178,7 @@
|
|||||||
// Capture only the initial streamUrl prop; later prop changes are applied via
|
// Capture only the initial streamUrl prop; later prop changes are applied via
|
||||||
// the $effect below (untrack keeps this a one-time snapshot, matching
|
// the $effect below (untrack keeps this a one-time snapshot, matching
|
||||||
// reportMediaId above and silencing state_referenced_locally).
|
// reportMediaId above and silencing state_referenced_locally).
|
||||||
// The selection currently loaded. Starts from the prop and is replaced
|
let currentStreamUrl = $state(untrack(() => streamUrl));
|
||||||
// wholesale by a reload (quality change, audio-track switch, transcoded seek)
|
|
||||||
// so transport and URL can never disagree.
|
|
||||||
// TRACES: UR-079 | DR-224
|
|
||||||
let currentSelection = $state<StreamSelection>(untrack(() => selection));
|
|
||||||
const currentStreamUrl = $derived(currentSelection.url);
|
|
||||||
let hasReportedStart = $state(false);
|
let hasReportedStart = $state(false);
|
||||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
||||||
@@ -263,31 +249,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A selection identical to the one loaded, but pointing at a different URL.
|
|
||||||
*
|
|
||||||
* Used by the paths that swap the stream without re-negotiating — the
|
|
||||||
* background-audio handoff and its return. Each states the transport it is
|
|
||||||
* moving to rather than letting it be inferred, which is the whole point of
|
|
||||||
* DR-224: the audio handoff really is a progressive mp3, and the rebuilt
|
|
||||||
* video stream really is an HLS transcode, and neither is knowable from the
|
|
||||||
* URL text.
|
|
||||||
*
|
|
||||||
* TRACES: UR-040, UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
|
|
||||||
// A re-opened stream is a new transcode job; the old session id is stale.
|
|
||||||
return { ...currentSelection, url, transport, playSessionId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const adapterBridge: Html5ElementBridge = {
|
const adapterBridge: Html5ElementBridge = {
|
||||||
getElement: () => videoElement,
|
getElement: () => videoElement,
|
||||||
getSeekOffset: () => seekOffset,
|
getSeekOffset: () => seekOffset,
|
||||||
setSeekOffset: (o) => {
|
setSeekOffset: (o) => {
|
||||||
seekOffset = o;
|
seekOffset = o;
|
||||||
},
|
},
|
||||||
setStreamSelection: (sel) => {
|
setStreamUrl: (u) => {
|
||||||
currentSelection = sel;
|
currentStreamUrl = u;
|
||||||
},
|
},
|
||||||
destroyHls: tearDownHls,
|
destroyHls: tearDownHls,
|
||||||
getMediaSourceId: () => mediaSourceId ?? null,
|
getMediaSourceId: () => mediaSourceId ?? null,
|
||||||
@@ -305,46 +274,9 @@
|
|||||||
// Rust — the frontend never encodes what a step means.
|
// Rust — the frontend never encodes what a step means.
|
||||||
// TRACES: UR-074 | DR-162
|
// TRACES: UR-074 | DR-162
|
||||||
let showQualityMenu = $state(false);
|
let showQualityMenu = $state(false);
|
||||||
|
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||||
|
let selectedQuality = $state<StreamingQuality>("original");
|
||||||
let changingQuality = $state(false);
|
let changingQuality = $state(false);
|
||||||
/**
|
|
||||||
* The device's durable default, shown when the stream is a direct play and so
|
|
||||||
* has no rendition of its own to report. Read once from Settings.
|
|
||||||
*/
|
|
||||||
let defaultQuality = $state<StreamingQuality>("original");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rungs to offer for the stream that is playing, straight from the
|
|
||||||
* backend (DR-226). Rungs whose ceiling is at or above the source bitrate are
|
|
||||||
* dropped: they produce the same bytes as Original, so listing five of them is
|
|
||||||
* five ways to spell one choice. Rust decides which those are — this only
|
|
||||||
* decides not to draw them.
|
|
||||||
*
|
|
||||||
* `Original` is always kept; it is the source, never redundant with it.
|
|
||||||
*
|
|
||||||
* TRACES: UR-070, UR-079 | DR-226, DR-121
|
|
||||||
*/
|
|
||||||
const qualityOptions = $derived(
|
|
||||||
currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rung in force. A transcode reports the rendition it was built against;
|
|
||||||
* a direct play has none, because it *is* the source — so it reads as
|
|
||||||
* Original rather than as whatever ceiling happens to be set.
|
|
||||||
*/
|
|
||||||
const selectedQuality = $derived<StreamingQuality>(
|
|
||||||
currentSelection.rendition?.quality ??
|
|
||||||
(currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Human line for what the server is doing with this stream. */
|
|
||||||
const playbackKindLabel = $derived(
|
|
||||||
currentSelection.playbackKind.type === "directPlay"
|
|
||||||
? "Direct play — the original file"
|
|
||||||
: currentSelection.playbackKind.type === "directStream"
|
|
||||||
? "Direct stream — repackaged, not re-encoded"
|
|
||||||
: "Transcoding on the server",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||||
let videoDuration = $state(0);
|
let videoDuration = $state(0);
|
||||||
@@ -517,9 +449,9 @@
|
|||||||
// Update stream URL when prop changes (from parent component, not from internal seeks)
|
// Update stream URL when prop changes (from parent component, not from internal seeks)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Only reset when the streamUrl prop actually changes from parent
|
// Only reset when the streamUrl prop actually changes from parent
|
||||||
if (selection.url !== lastStreamUrlProp) {
|
if (streamUrl !== lastStreamUrlProp) {
|
||||||
lastStreamUrlProp = selection.url;
|
lastStreamUrlProp = streamUrl;
|
||||||
currentSelection = selection;
|
currentStreamUrl = streamUrl;
|
||||||
seekOffset = 0;
|
seekOffset = 0;
|
||||||
isMediaReady = false; // Reset to loading state when stream URL changes
|
isMediaReady = false; // Reset to loading state when stream URL changes
|
||||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||||
@@ -634,14 +566,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The loader comes from the backend's tagged transport, never from the URL.
|
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||||
// TRACES: UR-079 | DR-224 | UT-213
|
|
||||||
const loader = videoLoaderFor(currentSelection, {
|
|
||||||
hlsJsSupported: Hls.isSupported(),
|
|
||||||
nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loader === "hlsjs") {
|
if (isHlsStream && Hls.isSupported()) {
|
||||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||||
if (hls) {
|
if (hls) {
|
||||||
log.debug("Cleaning up existing HLS instance");
|
log.debug("Cleaning up existing HLS instance");
|
||||||
@@ -796,13 +723,13 @@
|
|||||||
videoElement.pause();
|
videoElement.pause();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else if (loader === "nativeHls") {
|
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
// The element parses the playlist itself (Safari/WebKit).
|
// Native HLS support (Safari)
|
||||||
log.debug("Using native HLS support");
|
log.debug("Using native HLS support");
|
||||||
videoElement.src = currentStreamUrl;
|
videoElement.src = currentStreamUrl;
|
||||||
} else {
|
} else {
|
||||||
// Progressive or local: the element loads the URL directly.
|
// Not an HLS stream, use regular video element
|
||||||
log.debug("Using regular video element", currentSelection.transport.type);
|
log.debug("Using regular video element for non-HLS stream");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -873,25 +800,21 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The quality *ladder* now arrives with the stream selection (DR-226), so all
|
// Populate the quality menu. Deliberately its own *synchronous* onMount that
|
||||||
// this still needs is the device default, for the case where the stream is a
|
// fires the load without awaiting it: an await inside the main onMount below
|
||||||
// direct play and has no rendition of its own.
|
// flips the component into HTML5 mode and breaks native seeking, and nothing
|
||||||
|
// about playback waits on this list.
|
||||||
//
|
//
|
||||||
// Deliberately its own *synchronous* onMount that fires the load without
|
// TRACES: UR-074 | DR-162
|
||||||
// awaiting it: an await inside the main onMount below flips the component into
|
|
||||||
// HTML5 mode and breaks native seeking, and nothing about playback waits on
|
|
||||||
// this value.
|
|
||||||
//
|
|
||||||
// TRACES: UR-074, UR-079 | DR-162, DR-226
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
commands
|
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
|
||||||
.playerGetVideoSettings()
|
.then(([qualities, settings]) => {
|
||||||
.then((settings) => {
|
streamingQualities = qualities;
|
||||||
// Optional on the wire (serde default) — absent means uncapped.
|
// Optional on the wire (serde default) — absent means uncapped.
|
||||||
defaultQuality = settings.streamingQuality ?? "original";
|
selectedQuality = settings.streamingQuality ?? "original";
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
log.warn("Failed to load the default streaming quality:", err);
|
log.warn("Failed to load streaming qualities:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -954,9 +877,6 @@
|
|||||||
id: media.id,
|
id: media.id,
|
||||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||||
needsTranscoding: needsTranscoding,
|
needsTranscoding: needsTranscoding,
|
||||||
// Carry the negotiated transport onto the queue item so a later seek
|
|
||||||
// reads it instead of falling back. TRACES: UR-079 | DR-229
|
|
||||||
transport: currentSelection.transport,
|
|
||||||
// Order matters: player_set_subtitle_track(n) is a position in this
|
// Order matters: player_set_subtitle_track(n) is a position in this
|
||||||
// array. Previously this array was built and then dropped, so
|
// array. Previously this array was built and then dropped, so
|
||||||
// ExoPlayer got a MediaItem with no subtitles at all.
|
// ExoPlayer got a MediaItem with no subtitles at all.
|
||||||
@@ -1023,9 +943,7 @@
|
|||||||
const host = createRustReportHost(media.id, {
|
const host = createRustReportHost(media.id, {
|
||||||
onEnded: () => notifyEnded(),
|
onEnded: () => notifyEnded(),
|
||||||
onStreamUrlChanged: (u) => {
|
onStreamUrlChanged: (u) => {
|
||||||
// Rust re-opened the same stream (a transcoded seek): the
|
currentStreamUrl = u;
|
||||||
// transport is unchanged, only the job behind it.
|
|
||||||
currentSelection = selectionAt(u, currentSelection.transport);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
playerAdapter = createAdapter({
|
playerAdapter = createAdapter({
|
||||||
@@ -1912,9 +1830,8 @@
|
|||||||
|
|
||||||
pendingForegroundPlay = plan.shouldPlay;
|
pendingForegroundPlay = plan.shouldPlay;
|
||||||
|
|
||||||
// Determine the target stream + how the element/offset should be
|
// Determine the target URL + how the element/offset should be positioned.
|
||||||
// positioned.
|
let targetUrl: string;
|
||||||
let targetSelection: StreamSelection;
|
|
||||||
if (needsTranscoding && onSeek) {
|
if (needsTranscoding && onSeek) {
|
||||||
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
||||||
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
||||||
@@ -1925,16 +1842,13 @@
|
|||||||
// that really did start there; leaving it would now display `pos` while
|
// that really did start there; leaving it would now display `pos` while
|
||||||
// playing the opening titles.
|
// playing the opening titles.
|
||||||
// TRACES: UR-040, UR-004 | DR-181
|
// TRACES: UR-040, UR-004 | DR-181
|
||||||
// Every transcode this app requests is HLS (DR-140).
|
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
||||||
targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
|
|
||||||
type: "hls",
|
|
||||||
});
|
|
||||||
seekOffset = 0;
|
seekOffset = 0;
|
||||||
currentTime = pos;
|
currentTime = pos;
|
||||||
pendingForegroundSeek = pos;
|
pendingForegroundSeek = pos;
|
||||||
} else {
|
} else {
|
||||||
// Direct stream: reload the original selection and seek to pos.
|
// Direct stream: reload the original URL and seek the element to pos.
|
||||||
targetSelection = selection;
|
targetUrl = streamUrl;
|
||||||
seekOffset = 0;
|
seekOffset = 0;
|
||||||
pendingForegroundSeek = pos;
|
pendingForegroundSeek = pos;
|
||||||
}
|
}
|
||||||
@@ -1954,21 +1868,18 @@
|
|||||||
// than re-fetched.
|
// than re-fetched.
|
||||||
//
|
//
|
||||||
// TRACES: UR-040, UR-003 | DR-196
|
// TRACES: UR-040, UR-003 | DR-196
|
||||||
currentSelection = targetSelection;
|
currentStreamUrl = targetUrl;
|
||||||
await commands.playerPlayItem({
|
await commands.playerPlayItem({
|
||||||
streamUrl: targetSelection.url,
|
streamUrl: targetUrl,
|
||||||
title: media.name,
|
title: media.name,
|
||||||
id: media.id,
|
id: media.id,
|
||||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||||
needsTranscoding,
|
needsTranscoding,
|
||||||
// TRACES: UR-079 | DR-229
|
|
||||||
transport: targetSelection.transport,
|
|
||||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||||
});
|
});
|
||||||
didStartNativePlayback = true;
|
didStartNativePlayback = true;
|
||||||
await playerAdapter?.load(targetSelection.url, {
|
await playerAdapter?.load(targetUrl, {
|
||||||
mediaId: media.id,
|
mediaId: media.id,
|
||||||
selection: targetSelection,
|
|
||||||
mediaSourceId: mediaSourceId ?? null,
|
mediaSourceId: mediaSourceId ?? null,
|
||||||
needsTranscoding,
|
needsTranscoding,
|
||||||
initialPosition: plan.position,
|
initialPosition: plan.position,
|
||||||
@@ -1995,9 +1906,9 @@
|
|||||||
// blank it first, then set it on the next microtask so Svelte sees a real
|
// blank it first, then set it on the next microtask so Svelte sees a real
|
||||||
// transition. Without this, assigning the same value is a no-op and the
|
// transition. Without this, assigning the same value is a no-op and the
|
||||||
// player stays stuck on the loading spinner (HLS never re-initialises).
|
// player stays stuck on the loading spinner (HLS never re-initialises).
|
||||||
currentSelection = selectionAt("", targetSelection.transport);
|
currentStreamUrl = "";
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
currentSelection = targetSelection;
|
currentStreamUrl = targetUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Background-audio return failed:", err);
|
log.error("Background-audio return failed:", err);
|
||||||
}
|
}
|
||||||
@@ -2319,41 +2230,33 @@
|
|||||||
*
|
*
|
||||||
* The backend owns everything about how that happens — it decides whether the
|
* The backend owns everything about how that happens — it decides whether the
|
||||||
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
||||||
* only supplies the position to resume at.
|
* only supplies the position to resume at and reverts the selection if the
|
||||||
|
* switch fails.
|
||||||
*
|
*
|
||||||
* The change applies to this playback alone; the durable Settings default is
|
* TRACES: UR-074 | DR-162
|
||||||
* untouched (DR-225). Nothing is optimistically assigned here: what the picker
|
|
||||||
* shows comes from the selection the backend hands back, because what you get
|
|
||||||
* is not always what you asked for — a ceiling above the source bitrate is the
|
|
||||||
* source, and claiming otherwise is the kind of lie the old picker told.
|
|
||||||
*
|
|
||||||
* TRACES: UR-074, UR-079 | DR-162, DR-225, DR-226
|
|
||||||
*/
|
*/
|
||||||
async function selectQuality(quality: StreamingQuality) {
|
async function selectQuality(quality: StreamingQuality) {
|
||||||
showQualityMenu = false;
|
showQualityMenu = false;
|
||||||
if (quality === selectedQuality || changingQuality) return;
|
if (quality === selectedQuality || changingQuality) return;
|
||||||
|
|
||||||
|
const previous = selectedQuality;
|
||||||
|
selectedQuality = quality;
|
||||||
changingQuality = true;
|
changingQuality = true;
|
||||||
try {
|
try {
|
||||||
stopTimeUpdates();
|
stopTimeUpdates();
|
||||||
const negotiated = await playerController.setStreamQuality(
|
await playerController.setStreamQuality(
|
||||||
quality,
|
quality,
|
||||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||||
mediaSourceId ?? null,
|
mediaSourceId ?? null,
|
||||||
selectedAudioTrackIndex,
|
selectedAudioTrackIndex,
|
||||||
);
|
);
|
||||||
// The HTML5 path reloads through the adapter, which already set the new
|
|
||||||
// selection via the bridge. The native path reloads inside Rust and
|
|
||||||
// returns nothing, so record what was asked for as the ceiling in force.
|
|
||||||
if (!negotiated) {
|
|
||||||
defaultQuality = quality;
|
|
||||||
}
|
|
||||||
if (videoElement && !videoElement.paused) {
|
if (videoElement && !videoElement.paused) {
|
||||||
startTimeUpdates();
|
startTimeUpdates();
|
||||||
}
|
}
|
||||||
log.debug("Streaming quality changed:", quality);
|
log.debug("Streaming quality changed:", quality);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Failed to change streaming quality:", err);
|
log.error("Failed to change streaming quality:", err);
|
||||||
|
selectedQuality = previous;
|
||||||
} finally {
|
} finally {
|
||||||
changingQuality = false;
|
changingQuality = false;
|
||||||
}
|
}
|
||||||
@@ -2458,10 +2361,7 @@
|
|||||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={elementSrcFor(currentSelection, {
|
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
|
||||||
hlsJsSupported: Hls.isSupported(),
|
|
||||||
nativeHlsSupported: true,
|
|
||||||
})}
|
|
||||||
crossorigin={videoCrossOrigin}
|
crossorigin={videoCrossOrigin}
|
||||||
class={videoFitClass()}
|
class={videoFitClass()}
|
||||||
class:invisible={!isMediaReady}
|
class:invisible={!isMediaReady}
|
||||||
@@ -2801,11 +2701,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!--
|
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||||
Streaming quality (bandwidth ceiling), populated from what this media
|
{#if streamingQualities.length > 0}
|
||||||
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-226
|
|
||||||
-->
|
|
||||||
{#if qualityOptions.length > 1}
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={toggleQualityMenu}
|
onclick={toggleQualityMenu}
|
||||||
@@ -2826,30 +2723,22 @@
|
|||||||
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"
|
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="p-2">
|
||||||
<div class="px-3 py-2 border-b border-white/20">
|
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||||
<div class="text-white text-sm font-semibold">Quality</div>
|
Quality
|
||||||
<!--
|
|
||||||
What the server is actually doing. Only knowable now that
|
|
||||||
the backend reports it. TRACES: UR-079 | DR-227
|
|
||||||
-->
|
|
||||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#each qualityOptions as option (option.quality)}
|
{#each streamingQualities as [quality, label, detail]}
|
||||||
<button
|
<button
|
||||||
onclick={() => selectQuality(option.quality)}
|
onclick={() => selectQuality(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 ===
|
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
|
quality
|
||||||
? 'bg-white/20'
|
? 'bg-white/20'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm">{option.label}</span>
|
<span class="text-sm">{label}</span>
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">{detail}</span>
|
||||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
|
||||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if selectedQuality === option.quality}
|
{#if selectedQuality === quality}
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
|||||||
@@ -36,23 +36,6 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import VideoPlayer from "./VideoPlayer.svelte";
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
||||||
|
|
||||||
const toggleSpy = vi.fn();
|
const toggleSpy = vi.fn();
|
||||||
@@ -139,7 +122,7 @@ function touchAt(el: Element, x: number) {
|
|||||||
|
|
||||||
function renderPlayer() {
|
function renderPlayer() {
|
||||||
return render(VideoPlayer, {
|
return render(VideoPlayer, {
|
||||||
props: { media: MEDIA, selection: testSelection("http://x/master.m3u8"), onClose: vi.fn() },
|
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,23 +116,6 @@ import { tick } from "svelte";
|
|||||||
import VideoPlayer from "./VideoPlayer.svelte";
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEpisode(): MediaItem {
|
function makeEpisode(): MediaItem {
|
||||||
return {
|
return {
|
||||||
id: "ep1",
|
id: "ep1",
|
||||||
@@ -146,7 +129,7 @@ async function mountAndroidPlayer() {
|
|||||||
const utils = render(VideoPlayer, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
media: makeEpisode(),
|
||||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
mediaSourceId: "src-1",
|
mediaSourceId: "src-1",
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
|
|||||||
@@ -10,23 +10,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||||
import type { AdapterHost } from "./types";
|
import type { AdapterHost } from "./types";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A minimal fake <video> element that records mutations and fires events. */
|
/** A minimal fake <video> element that records mutations and fires events. */
|
||||||
function makeFakeVideo() {
|
function makeFakeVideo() {
|
||||||
const listeners: Record<string, Array<() => void>> = {};
|
const listeners: Record<string, Array<() => void>> = {};
|
||||||
@@ -71,7 +54,7 @@ function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBr
|
|||||||
setSeekOffset: vi.fn((o: number) => {
|
setSeekOffset: vi.fn((o: number) => {
|
||||||
offset = o;
|
offset = o;
|
||||||
}),
|
}),
|
||||||
setStreamSelection: vi.fn(),
|
setStreamUrl: vi.fn(),
|
||||||
destroyHls: vi.fn(),
|
destroyHls: vi.fn(),
|
||||||
getMediaSourceId: () => "msid-1",
|
getMediaSourceId: () => "msid-1",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -201,7 +184,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
|
|
||||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
||||||
video.paused = false; // was playing → should resume
|
video.paused = false; // was playing → should resume
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||||
|
|
||||||
// Teardown happened synchronously before the awaited canplay wait.
|
// Teardown happened synchronously before the awaited canplay wait.
|
||||||
expect(video.pause).toHaveBeenCalled();
|
expect(video.pause).toHaveBeenCalled();
|
||||||
@@ -211,9 +194,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
|
|
||||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
||||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
|
||||||
);
|
|
||||||
video._fire("canplay");
|
video._fire("canplay");
|
||||||
video._fire("seeked");
|
video._fire("seeked");
|
||||||
await p;
|
await p;
|
||||||
@@ -236,7 +217,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
*/
|
*/
|
||||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||||
@@ -257,7 +238,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
/** A reload to the very start has nothing to seek to; it must not stall. */
|
/** A reload to the very start has nothing to seek to; it must not stall. */
|
||||||
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
|
const p = adapter.reloadSource("http://new/master.m3u8", 0);
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
video._fire("canplay");
|
video._fire("canplay");
|
||||||
await p; // resolves without any "seeked" event
|
await p; // resolves without any "seeked" event
|
||||||
@@ -277,7 +258,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||||
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
||||||
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
||||||
await assertion;
|
await assertion;
|
||||||
@@ -289,7 +270,7 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
|
|
||||||
it("reloadSource() does not resume when it was paused", async () => {
|
it("reloadSource() does not resume when it was paused", async () => {
|
||||||
video.paused = true;
|
video.paused = true;
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
video._fire("canplay");
|
video._fire("canplay");
|
||||||
video._fire("seeked");
|
video._fire("seeked");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
|
||||||
/**
|
/**
|
||||||
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
||||||
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
||||||
@@ -25,39 +24,6 @@ import { createLogger } from "$lib/utils/logger";
|
|||||||
|
|
||||||
const log = createLogger("Html5PlayerAdapter");
|
const log = createLogger("Html5PlayerAdapter");
|
||||||
|
|
||||||
/**
|
|
||||||
* The selection for a plain `load(url)` call.
|
|
||||||
*
|
|
||||||
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
|
|
||||||
* When it does not — a local file, a live stream, a direct URL — the transport
|
|
||||||
* is inferred *once, here*, from what the caller already knows rather than from
|
|
||||||
* the URL text: a local path is a local file, and anything the backend flagged
|
|
||||||
* as transcoded is HLS, because every transcode this app requests is HLS.
|
|
||||||
*
|
|
||||||
* This is the one place a fallback is tolerable, and it is explicitly a
|
|
||||||
* fallback: the negotiated path never reaches it.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
|
|
||||||
if (options.selection) return options.selection;
|
|
||||||
const transport: StreamSelection["transport"] = options.isLocalFile
|
|
||||||
? { type: "localFile" }
|
|
||||||
: options.needsTranscoding
|
|
||||||
? { type: "hls" }
|
|
||||||
: { type: "progressive" };
|
|
||||||
return {
|
|
||||||
url: streamUrl,
|
|
||||||
transport,
|
|
||||||
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: options.mediaSourceId ?? null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: options.needsTranscoding,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrow seam the owning component provides so the adapter can execute the
|
* Narrow seam the owning component provides so the adapter can execute the
|
||||||
* element/HLS-coupled parts of a control action without re-implementing the
|
* element/HLS-coupled parts of a control action without re-implementing the
|
||||||
@@ -70,16 +36,8 @@ export interface Html5ElementBridge {
|
|||||||
/** Current seek offset (seconds) for transcoded streams. */
|
/** Current seek offset (seconds) for transcoded streams. */
|
||||||
getSeekOffset(): number;
|
getSeekOffset(): number;
|
||||||
setSeekOffset(offset: number): void;
|
setSeekOffset(offset: number): void;
|
||||||
/**
|
/** Update the stream URL the component renders (triggers its HLS $effect). */
|
||||||
* Update the stream the component renders (triggers its HLS $effect).
|
setStreamUrl(url: string): void;
|
||||||
*
|
|
||||||
* Carries the whole [`StreamSelection`], not just the URL: the component's
|
|
||||||
* effect has to know the transport to choose a loader, and deriving that from
|
|
||||||
* the URL is the substring check DR-224 removes.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
setStreamSelection(selection: StreamSelection): void;
|
|
||||||
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
||||||
destroyHls(): void;
|
destroyHls(): void;
|
||||||
/** Media source id for seek/audio-track URLs. */
|
/** Media source id for seek/audio-track URLs. */
|
||||||
@@ -128,13 +86,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
this.attachedElement = element;
|
this.attachedElement = element;
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
|
||||||
// The component's reactive HLS $effect performs the actual attach/load when
|
// The component's reactive HLS $effect performs the actual attach/load when
|
||||||
// the selection is set; loading is therefore driven by setStreamSelection.
|
// the stream URL is set; loading is therefore driven by setStreamUrl. The
|
||||||
// The component's canplay/frag-buffered path reports readiness through the
|
// component's canplay/frag-buffered path reports readiness through the host.
|
||||||
// host.
|
|
||||||
this.bridge.setSeekOffset(0);
|
this.bridge.setSeekOffset(0);
|
||||||
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
|
this.bridge.setStreamUrl(streamUrl);
|
||||||
this.host.onState("loading");
|
this.host.onState("loading");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +171,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
*
|
*
|
||||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||||
*/
|
*/
|
||||||
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
|
async reloadSource(url: string, positionSeconds: number): Promise<void> {
|
||||||
const el = this.element;
|
const el = this.element;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
// Still update the selection so the component's HLS $effect can pick it up.
|
// Still update the stream URL so the component's HLS $effect can pick it up.
|
||||||
this.bridge.setSeekOffset(0);
|
this.bridge.setSeekOffset(0);
|
||||||
this.bridge.setStreamSelection(selection);
|
this.bridge.setStreamUrl(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wasPlaying = !el.paused;
|
const wasPlaying = !el.paused;
|
||||||
@@ -232,7 +189,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
// The reloaded stream begins at the item's zero, so there is no base to add.
|
// The reloaded stream begins at the item's zero, so there is no base to add.
|
||||||
this.bridge.setSeekOffset(0);
|
this.bridge.setSeekOffset(0);
|
||||||
this.bridge.setStreamSelection(selection);
|
this.bridge.setStreamUrl(url);
|
||||||
// A source that never becomes playable is a failed reload, not a slow one:
|
// A source that never becomes playable is a failed reload, not a slow one:
|
||||||
// the caller (quality switch, transcoded seek) has to know so it can revert
|
// the caller (quality switch, transcoded seek) has to know so it can revert
|
||||||
// its selection and surface the error instead of leaving the UI claiming a
|
// its selection and surface the error instead of leaving the UI claiming a
|
||||||
|
|||||||
@@ -28,23 +28,6 @@ vi.mock("$lib/api/bindings", () => ({
|
|||||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||||
import type { AdapterHost } from "./types";
|
import type { AdapterHost } from "./types";
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHost(): AdapterHost {
|
function makeHost(): AdapterHost {
|
||||||
return {
|
return {
|
||||||
onState: vi.fn(),
|
onState: vi.fn(),
|
||||||
@@ -84,7 +67,7 @@ describe("NativePlayerAdapter", () => {
|
|||||||
it("records position on seek/reload primitives (backend does the real work)", async () => {
|
it("records position on seek/reload primitives (backend does the real work)", async () => {
|
||||||
await adapter.seekElement(55, 0);
|
await adapter.seekElement(55, 0);
|
||||||
expect(adapter.getPosition()).toBe(55);
|
expect(adapter.getPosition()).toBe(55);
|
||||||
await adapter.reloadSource(testSelection("ignored"), 200);
|
await adapter.reloadSource("ignored", 200);
|
||||||
expect(adapter.getPosition()).toBe(200);
|
expect(adapter.getPosition()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
|
||||||
/**
|
/**
|
||||||
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
||||||
*
|
*
|
||||||
@@ -90,7 +89,7 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
|||||||
* performed the reload+seek internally as part of the seek decision; nothing
|
* performed the reload+seek internally as part of the seek decision; nothing
|
||||||
* to do on the frontend beyond recording position.
|
* to do on the frontend beyond recording position.
|
||||||
*/
|
*/
|
||||||
async reloadSource(_selection: StreamSelection, offset: number): Promise<void> {
|
async reloadSource(_url: string, offset: number): Promise<void> {
|
||||||
this.position = offset;
|
this.position = offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
|
||||||
/**
|
/**
|
||||||
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
|
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
|
||||||
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
|
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
|
||||||
@@ -43,19 +42,6 @@ export interface PlayerLoadOptions {
|
|||||||
knownDuration: number;
|
knownDuration: number;
|
||||||
/** Subtitle tracks available for this media. */
|
/** Subtitle tracks available for this media. */
|
||||||
subtitleTracks: SubtitleTrackInput[];
|
subtitleTracks: SubtitleTrackInput[];
|
||||||
/**
|
|
||||||
* The backend's decision about this stream, when it made one.
|
|
||||||
*
|
|
||||||
* Present for anything negotiated through `repository_get_stream_selection`.
|
|
||||||
* Null for the paths that never negotiate — a local file, a live channel, a
|
|
||||||
* plugin's direct URL — where the adapter falls back to what the other
|
|
||||||
* options already say rather than to reading the URL.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
selection?: StreamSelection | null;
|
|
||||||
/** The source is a file on disk (or the loopback server in front of one). */
|
|
||||||
isLocalFile?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,16 +106,12 @@ export interface PlayerAdapter {
|
|||||||
seekElement(positionSeconds: number, offset: number): Promise<void>;
|
seekElement(positionSeconds: number, offset: number): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
|
* Compound reload: swap to `url` and resume at `offset` seconds. Runs the
|
||||||
* the invariant mechanical sequence for this platform (html5: pause → hls
|
* invariant mechanical sequence for this platform (html5: pause → hls teardown
|
||||||
* teardown → clear src → set new selection → wait ready → resume; native:
|
* → clear src → set new url → wait ready → resume; native: ExoPlayer setMediaItem
|
||||||
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
|
* + seekTo). No decision is made here — the backend already decided to reload.
|
||||||
* already decided to reload, and `selection.transport` says how to open it, so
|
|
||||||
* no adapter has to infer that from the URL.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
*/
|
||||||
reloadSource(selection: StreamSelection, offset: number): Promise<void>;
|
reloadSource(url: string, offset: number): Promise<void>;
|
||||||
|
|
||||||
setVolume(volume: number): void; // 0..1
|
setVolume(volume: number): void; // 0..1
|
||||||
setMuted(muted: boolean): void;
|
setMuted(muted: boolean): void;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
|
||||||
/**
|
/**
|
||||||
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
||||||
* element on platforms with no native audio backend (currently Windows).
|
* element on platforms with no native audio backend (currently Windows).
|
||||||
@@ -105,8 +104,8 @@ export class WebviewAudioAdapter implements PlayerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** No transcode-reload concept for direct audio; treat as a fresh load. */
|
/** No transcode-reload concept for direct audio; treat as a fresh load. */
|
||||||
async reloadSource(selection: StreamSelection, offset: number): Promise<void> {
|
async reloadSource(url: string, offset: number): Promise<void> {
|
||||||
await this.load(selection.url, {
|
await this.load(url, {
|
||||||
mediaId: "",
|
mediaId: "",
|
||||||
mediaSourceId: null,
|
mediaSourceId: null,
|
||||||
needsTranscoding: false,
|
needsTranscoding: false,
|
||||||
|
|||||||
+10
-19
@@ -22,7 +22,6 @@ import type {
|
|||||||
PlayAlbumTrackRequest,
|
PlayAlbumTrackRequest,
|
||||||
PlayItemRequest,
|
PlayItemRequest,
|
||||||
StreamingQuality,
|
StreamingQuality,
|
||||||
StreamSelection,
|
|
||||||
} from "$lib/api/bindings";
|
} from "$lib/api/bindings";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import type { PlayerAdapter } from "./adapters/types";
|
import type { PlayerAdapter } from "./adapters/types";
|
||||||
@@ -151,12 +150,12 @@ async function seekVideo(
|
|||||||
audioTrackIndex,
|
audioTrackIndex,
|
||||||
adapter.kind === "html5",
|
adapter.kind === "html5",
|
||||||
)) as any;
|
)) as any;
|
||||||
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
|
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||||
if (response.strategy === "reloadStream") {
|
if (response.strategy === "reloadStream") {
|
||||||
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
|
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
|
||||||
// the element's clock: the reloaded stream starts at the item's zero since
|
// the element's clock: the reloaded stream starts at the item's zero since
|
||||||
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
|
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
|
||||||
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
|
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
|
||||||
} else {
|
} else {
|
||||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||||
}
|
}
|
||||||
@@ -183,32 +182,26 @@ async function switchAudioTrack(
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
)) as any;
|
)) as any;
|
||||||
if (response.strategy === "reloadStream") {
|
if (response.strategy === "reloadStream") {
|
||||||
await adapter.reloadSource(response.selection, response.position!);
|
await adapter.reloadSource(response.new_url!, response.position!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the bandwidth ceiling of the video playing now. The backend re-opens
|
* Change the bandwidth ceiling of the video playing now. The backend re-opens
|
||||||
* the stream at the new quality and decides who reloads: it handles a native
|
* the stream at the new quality and decides who reloads: it handles a native
|
||||||
* backend itself, and hands HTML5 a selection for the same `reloadSource`
|
* backend itself, and hands HTML5 a URL for the same `reloadSource` primitive
|
||||||
* primitive the audio-track switch uses. Requires an active video adapter.
|
* the audio-track switch uses. Requires an active video adapter.
|
||||||
*
|
*
|
||||||
* The change applies to **this playback only** — the backend sets a per-playback
|
* TRACES: UR-074 | DR-162
|
||||||
* override that the next item clears, leaving the durable Settings default
|
|
||||||
* alone. Returns the negotiated selection so the caller can show what it
|
|
||||||
* actually got, which is not always what was asked for: a ceiling above the
|
|
||||||
* source bitrate is the source.
|
|
||||||
*
|
|
||||||
* TRACES: UR-074, UR-079 | DR-162, DR-225
|
|
||||||
*/
|
*/
|
||||||
async function setStreamQuality(
|
async function setStreamQuality(
|
||||||
quality: StreamingQuality,
|
quality: StreamingQuality,
|
||||||
currentPosition: number | null,
|
currentPosition: number | null,
|
||||||
mediaSourceId: string | null,
|
mediaSourceId: string | null,
|
||||||
audioTrackIndex: number | null,
|
audioTrackIndex: number | null,
|
||||||
): Promise<StreamSelection | null> {
|
): Promise<void> {
|
||||||
const adapter = activeAdapter;
|
const adapter = activeAdapter;
|
||||||
if (!adapter) return null;
|
if (!adapter) return;
|
||||||
const response = (await commands.playerSetStreamQuality(
|
const response = (await commands.playerSetStreamQuality(
|
||||||
requireHandle(),
|
requireHandle(),
|
||||||
quality,
|
quality,
|
||||||
@@ -217,12 +210,10 @@ async function setStreamQuality(
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
audioTrackIndex,
|
audioTrackIndex,
|
||||||
)) as any;
|
)) as any;
|
||||||
|
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||||
if (response.strategy === "reloadStream") {
|
if (response.strategy === "reloadStream") {
|
||||||
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
|
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
|
||||||
return response.selection;
|
|
||||||
}
|
}
|
||||||
// A native backend reloaded itself; there is no selection on that branch.
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function next() {
|
async function next() {
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* The loader is chosen from the backend's `transport` tag, never from the URL.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224 | UT-213
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
|
|
||||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
|
||||||
|
|
||||||
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
|
|
||||||
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
|
|
||||||
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
|
|
||||||
|
|
||||||
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
|
|
||||||
return { url, transport };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("videoLoaderFor", () => {
|
|
||||||
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
|
|
||||||
"hlsjs",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
|
||||||
"nativeHls",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads a progressive stream directly", () => {
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(
|
|
||||||
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
|
|
||||||
MODERN,
|
|
||||||
),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads a local file directly", () => {
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// The two cases the `.m3u8` substring check gets wrong. These are the
|
|
||||||
// reason the field exists; both fail against a URL-sniffing implementation.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
|
|
||||||
// A direct play served from a path containing the substring — nothing stops
|
|
||||||
// a server, a proxy, or a local cache from producing this.
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
|
|
||||||
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
|
|
||||||
// DASH or query-routed playlist endpoint never would.
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
|
|
||||||
"hlsjs",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
|
|
||||||
).toBe("nativeHls");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to direct when HLS is requested but nothing can play it", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
|
|
||||||
"direct",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementSrcFor", () => {
|
|
||||||
it("empties the element's src only when hls.js drives it", () => {
|
|
||||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
|
|
||||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
|
||||||
"https://s/master.m3u8",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the src for a progressive stream that looks like a playlist", () => {
|
|
||||||
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
|
|
||||||
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Which loader opens a stream in the webview `<video>` element.
|
|
||||||
*
|
|
||||||
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
|
|
||||||
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224 | UT-213
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
|
||||||
|
|
||||||
/** How the element should be fed. */
|
|
||||||
export type VideoLoader =
|
|
||||||
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
|
|
||||||
| "hlsjs"
|
|
||||||
/** The element loads the playlist itself (Safari/WebKit native HLS). */
|
|
||||||
| "nativeHls"
|
|
||||||
/** The element loads the URL directly — a progressive file or a local one. */
|
|
||||||
| "direct";
|
|
||||||
|
|
||||||
/** What the running browser can do, passed in so the decision stays pure. */
|
|
||||||
export interface LoaderCapabilities {
|
|
||||||
/** `Hls.isSupported()` */
|
|
||||||
hlsJsSupported: boolean;
|
|
||||||
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
|
|
||||||
nativeHlsSupported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the loader from the backend's tagged `transport`.
|
|
||||||
*
|
|
||||||
* This used to read `url.includes(".m3u8")`, in two places in
|
|
||||||
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
|
|
||||||
* re-deriving the answer here by substring match is a domain fact reconstructed
|
|
||||||
* in the presentation layer — the same error as leaking item-type taxonomy, and
|
|
||||||
* one that fails silently in both directions: a progressive file served from a
|
|
||||||
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
|
|
||||||
* without it does not.
|
|
||||||
*
|
|
||||||
* The transport is the *stream's* property; whether a given loader exists is the
|
|
||||||
* *browser's*. Only the second is decided here.
|
|
||||||
*/
|
|
||||||
export function videoLoaderFor(
|
|
||||||
selection: Pick<StreamSelection, "url" | "transport">,
|
|
||||||
capabilities: LoaderCapabilities,
|
|
||||||
): VideoLoader {
|
|
||||||
if (selection.transport.type !== "hls") {
|
|
||||||
// Progressive and local files are what the element loads natively. No
|
|
||||||
// MediaSource, no playlist parsing.
|
|
||||||
return "direct";
|
|
||||||
}
|
|
||||||
if (capabilities.hlsJsSupported) return "hlsjs";
|
|
||||||
if (capabilities.nativeHlsSupported) return "nativeHls";
|
|
||||||
// Nothing here can parse a playlist. Handing the URL to the element is very
|
|
||||||
// likely to fail, but it is the only remaining move and it surfaces a real
|
|
||||||
// media error rather than silently doing nothing.
|
|
||||||
return "direct";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience for the template: does the element's `src` stay empty? */
|
|
||||||
export function elementSrcFor(
|
|
||||||
selection: Pick<StreamSelection, "url" | "transport">,
|
|
||||||
capabilities: LoaderCapabilities,
|
|
||||||
): string {
|
|
||||||
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Transport };
|
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { downloadedFilePath } from "$lib/player/localSource";
|
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource";
|
||||||
import type { PlayQueueRequest, StreamSelection } from "$lib/api/bindings";
|
import type { PlayQueueRequest } from "$lib/api/bindings";
|
||||||
import type { MediaItem, MediaKind } from "$lib/api/types";
|
import type { MediaItem, MediaKind } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
@@ -76,15 +76,7 @@
|
|||||||
const hasNext = $derived($hasNextStore);
|
const hasNext = $derived($hasNextStore);
|
||||||
const hasPrevious = $derived($hasPreviousStore);
|
const hasPrevious = $derived($hasPreviousStore);
|
||||||
let currentMedia = $state<MediaItem | null>(null);
|
let currentMedia = $state<MediaItem | null>(null);
|
||||||
/**
|
let streamUrl = $state<string | null>(null);
|
||||||
* What to play, as the backend decided it. Null while still resolving.
|
|
||||||
*
|
|
||||||
* Replaces a bare URL string: the transport travels with it, so neither this
|
|
||||||
* page nor VideoPlayer has to work out whether the URL is a playlist.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-224
|
|
||||||
*/
|
|
||||||
let selection = $state<StreamSelection | null>(null);
|
|
||||||
let mediaSourceId = $state<string | null>(null);
|
let mediaSourceId = $state<string | null>(null);
|
||||||
let isVideo = $state(false);
|
let isVideo = $state(false);
|
||||||
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
|
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
|
||||||
@@ -102,7 +94,7 @@
|
|||||||
|
|
||||||
// Which player component to render. Video without a stream URL is "pending"
|
// Which player component to render. Video without a stream URL is "pending"
|
||||||
// (still resolving), never audio — see playerSurface.ts.
|
// (still resolving), never audio — see playerSurface.ts.
|
||||||
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl: selection?.url ?? null }));
|
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Start position polling (only for audio via MPV backend)
|
// Start position polling (only for audio via MPV backend)
|
||||||
@@ -316,17 +308,17 @@
|
|||||||
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
|
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
|
||||||
log.debug("loadAndPlay: Full local path:", fullPath);
|
log.debug("loadAndPlay: Full local path:", fullPath);
|
||||||
|
|
||||||
if (isVideo) {
|
// Serve the file over the loopback media server rather than the asset
|
||||||
// Served over the loopback media server rather than the asset
|
|
||||||
// protocol: the asset protocol answers a range-less request with the
|
// protocol: the asset protocol answers a range-less request with the
|
||||||
// entire file, so a downloaded film never finished loading. Rust mints
|
// entire file, so a downloaded film never finished loading. Rust mints
|
||||||
// the URL (it holds the port and the per-session token) and states the
|
// the URL (it holds the port and the per-session token).
|
||||||
// transport with it.
|
// TRACES: UR-071 | DR-137
|
||||||
//
|
const localUrl = await commands.mediaLocalUrl(fullPath);
|
||||||
// A downloaded file is a direct play over a local transport, and Rust
|
log.debug("loadAndPlay: Local media URL resolved");
|
||||||
// says so rather than this page assuming it.
|
|
||||||
// TRACES: UR-071 | DR-137, DR-224
|
if (isVideo) {
|
||||||
selection = await commands.mediaLocalSelection(fullPath);
|
// Local video files don't need transcoding and support native seeking
|
||||||
|
streamUrl = localUrl;
|
||||||
videoNeedsTranscoding = false;
|
videoNeedsTranscoding = false;
|
||||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||||
@@ -363,19 +355,7 @@
|
|||||||
const liveInfo = await repo.openLiveStream(id);
|
const liveInfo = await repo.openLiveStream(id);
|
||||||
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
||||||
mediaSourceId = liveInfo.mediaSourceId;
|
mediaSourceId = liveInfo.mediaSourceId;
|
||||||
selection = {
|
streamUrl = liveInfo.streamUrl;
|
||||||
url: liveInfo.streamUrl,
|
|
||||||
// Rust's verdict, not a guess from the URL.
|
|
||||||
transport: liveInfo.transport,
|
|
||||||
playbackKind: { type: "transcode" },
|
|
||||||
rendition: null,
|
|
||||||
// A live channel has no ladder to offer: there is no source file to
|
|
||||||
// measure and no rendition to re-negotiate against.
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: liveInfo.mediaSourceId,
|
|
||||||
playSessionId: liveInfo.playSessionId,
|
|
||||||
needsTranscoding: true,
|
|
||||||
};
|
|
||||||
videoNeedsTranscoding = true;
|
videoNeedsTranscoding = true;
|
||||||
videoInitialPosition = 0;
|
videoInitialPosition = 0;
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
@@ -383,46 +363,46 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug("loadAndPlay: Getting playback info");
|
||||||
|
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||||
|
log.debug("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||||
|
log.debug(
|
||||||
|
"loadAndPlay: Using video stream, directPlay:",
|
||||||
|
playbackInfo.directPlay,
|
||||||
|
"needsTranscoding:",
|
||||||
|
playbackInfo.needsTranscoding,
|
||||||
|
);
|
||||||
|
mediaSourceId = playbackInfo.mediaSourceId;
|
||||||
|
|
||||||
// Prefer a completed download over streaming. Audio has done this
|
// Prefer a completed download over streaming. Audio has done this
|
||||||
// since the queue is built; video previously always streamed, so a
|
// since the queue is built; video previously always streamed, so a
|
||||||
// downloaded film re-spent bandwidth already spent and would not play
|
// downloaded film re-spent bandwidth already spent and would not play
|
||||||
// at all offline. Rust returns null when nothing is downloaded or the
|
// at all offline. Rust returns null when nothing is downloaded or the
|
||||||
// file has gone, so this falls back to the server on its own.
|
// file has gone, so this falls back to the server on its own.
|
||||||
//
|
// TRACES: UR-071 | DR-123
|
||||||
// Checked *first* so the streaming path below negotiates exactly once:
|
// A downloaded file is served over the loopback media server, not the
|
||||||
// asking for a `PlaybackInfo` and then a stream selection meant two
|
// asset protocol — see DR-137. The URL is minted up front because
|
||||||
// negotiations per load, and each one claims a transcode identity and
|
// resolveVideoSource stays pure/synchronous.
|
||||||
// retires the previous — so the server started a job only to be told
|
// TRACES: UR-071 | DR-123, DR-137
|
||||||
// to stop it a moment later. Observed in the log as a pair of
|
|
||||||
// `[StreamSelection]` lines for one play.
|
|
||||||
//
|
|
||||||
// TRACES: UR-071 | DR-123, DR-137, DR-224
|
|
||||||
const localPath = await commands.playerLocalMediaPath(id);
|
const localPath = await commands.playerLocalMediaPath(id);
|
||||||
if (localPath) {
|
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null;
|
||||||
// A downloaded file is a direct play over a local transport, served
|
const source = resolveVideoSource({
|
||||||
// by the loopback media server rather than the asset protocol
|
localPath,
|
||||||
// (DR-137). Its media-source id still comes from the server, since
|
remoteUrl: playbackInfo.streamUrl,
|
||||||
// that is what subtitle URLs are keyed by.
|
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
|
||||||
selection = await commands.mediaLocalSelection(localPath);
|
toAssetUrl: () => localUrl ?? "",
|
||||||
videoNeedsTranscoding = false;
|
});
|
||||||
mediaSourceId = (await repo.getPlaybackInfo(id)).mediaSourceId;
|
|
||||||
log.debug("loadAndPlay: Playing downloaded file from disk");
|
streamUrl = source.url;
|
||||||
} else {
|
videoNeedsTranscoding = source.needsTranscoding;
|
||||||
// Rust negotiates direct play vs direct stream vs transcode against
|
|
||||||
// the device profile and the ceiling in force, and returns the
|
|
||||||
// transport and the media-source id with it. This page no longer
|
|
||||||
// decides — or separately asks for — any of that.
|
|
||||||
// TRACES: UR-070, UR-079 | DR-224, DR-226, DR-227
|
|
||||||
selection = await repo.getStreamSelection(id, null, null);
|
|
||||||
mediaSourceId = selection.mediaSourceId;
|
|
||||||
// Rust's own verdict — "which kinds count as transcoding" is a
|
|
||||||
// domain rule, and a direct *stream* is a remux that does not.
|
|
||||||
videoNeedsTranscoding = selection.needsTranscoding;
|
|
||||||
log.debug(
|
log.debug(
|
||||||
`loadAndPlay: ${selection.playbackKind.type} over ${selection.transport.type}`,
|
source.isLocal
|
||||||
|
? "loadAndPlay: Playing downloaded file from disk"
|
||||||
|
: `loadAndPlay: Using stream URL: ${streamUrl}`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial position for the video player to seek to after load.
|
// Set initial position for the video player to seek to after load.
|
||||||
// Use explicit startPosition, or fall back to retrieved progress.
|
// Use explicit startPosition, or fall back to retrieved progress.
|
||||||
@@ -867,10 +847,10 @@
|
|||||||
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if surface === "video" && selection}
|
{:else if surface === "video" && streamUrl}
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
media={currentMedia}
|
media={currentMedia}
|
||||||
{selection}
|
{streamUrl}
|
||||||
mediaSourceId={mediaSourceId ?? undefined}
|
mediaSourceId={mediaSourceId ?? undefined}
|
||||||
initialPosition={videoInitialPosition}
|
initialPosition={videoInitialPosition}
|
||||||
needsTranscoding={videoNeedsTranscoding}
|
needsTranscoding={videoNeedsTranscoding}
|
||||||
|
|||||||
Reference in New Issue
Block a user