Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d095e1f410 | ||
|
|
a7365b9511 | ||
|
|
16658889a2 | ||
|
|
98b2ede8bd | ||
|
|
38d56e6c89 | ||
|
|
4f4741cee5 |
@@ -13,6 +13,10 @@ on:
|
|||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
@@ -33,13 +37,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -129,13 +142,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ on:
|
|||||||
env:
|
env:
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -27,13 +29,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -88,21 +99,25 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# ⚠️ Key must NOT collide with the test job's `cargo-host` key: the test
|
|
||||||
# job runs first and saves debug/clippy artifacts under its key, and
|
|
||||||
# actions/cache skips saving on an exact-key hit — so a shared key meant
|
|
||||||
# this job's *release* artifacts were never cached and every Linux release
|
|
||||||
# build compiled cold (~31min vs ~9min for the correctly-keyed Windows job).
|
|
||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-linux-release-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-linux-release-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -139,14 +154,19 @@ jobs:
|
|||||||
#
|
#
|
||||||
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
|
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
|
||||||
# than one match `test` gets extra arguments and fails.
|
# than one match `test` gets extra arguments and fails.
|
||||||
shopt -s nullglob
|
#
|
||||||
|
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
|
||||||
|
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
|
||||||
|
# step (which is why v0.9.0 and v0.9.1 built but never published).
|
||||||
|
# Without nullglob an unmatched pattern stays literal, so test each
|
||||||
|
# candidate instead. Same POSIX-only rule as traceability-check.yml.
|
||||||
for bundle in \
|
for bundle in \
|
||||||
src-tauri/target/release/bundle/appimage/*.AppImage \
|
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||||||
src-tauri/target/release/bundle/deb/*.deb \
|
src-tauri/target/release/bundle/deb/*.deb \
|
||||||
src-tauri/target/release/bundle/rpm/*.rpm; do
|
src-tauri/target/release/bundle/rpm/*.rpm; do
|
||||||
|
[ -e "$bundle" ] || continue
|
||||||
cp -v "$bundle" dist/linux/
|
cp -v "$bundle" dist/linux/
|
||||||
done
|
done
|
||||||
shopt -u nullglob
|
|
||||||
|
|
||||||
# A release with no Linux package is a failure, not a quiet success.
|
# A release with no Linux package is a failure, not a quiet success.
|
||||||
if [ -z "$(ls -A dist/linux/)" ]; then
|
if [ -z "$(ls -A dist/linux/)" ]; then
|
||||||
@@ -160,7 +180,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-linux
|
name: jellytau-linux
|
||||||
path: dist/linux/
|
path: dist/linux/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build Windows
|
name: Build Windows
|
||||||
@@ -178,14 +198,31 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
~/.cache/cargo-xwin
|
~/.cargo/git/db
|
||||||
src-tauri/target
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-windows-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache Windows CRT/SDK (cargo-xwin)
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/cargo-xwin
|
||||||
|
# Contents track the xwin version baked into the builder image, not our
|
||||||
|
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
|
||||||
|
# every release bump. Bump the suffix by hand if the image's xwin moves.
|
||||||
|
key: ${{ runner.os }}-cargo-xwin-v1
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -216,7 +253,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-windows
|
name: jellytau-windows
|
||||||
path: dist/windows/
|
path: dist/windows/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build Android
|
name: Build Android
|
||||||
@@ -235,17 +272,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
# `-release` suffix keeps this distinct from build-and-test.yml's
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
# android-check key, whose `cargo check` artifacts would otherwise
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
# claim the key first and block this job's release cache from ever
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
# being saved (same collision as the Linux job above).
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
key: ${{ runner.os }}-cargo-android-release-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-release-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -309,7 +351,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-android
|
name: jellytau-android
|
||||||
path: dist/android/
|
path: dist/android/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ 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.9.1
|
||||||
|
|
||||||
|
A one-line fix to the home screen, released on its own because it is the kind of
|
||||||
|
small wrongness you notice every time.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Swiping the hero banner now buys you a full six seconds.** The rotation
|
||||||
|
timer was started once when the banner appeared and then left alone, so a
|
||||||
|
swipe, arrow or dot tap inherited whatever was left of the running countdown
|
||||||
|
— swipe five and a half seconds in and the banner moved on half a second
|
||||||
|
later, before you had read the title. Any manual change now restarts the
|
||||||
|
countdown from that moment. (UR-034 → DR-038)
|
||||||
|
|
||||||
## v0.9.0
|
## v0.9.0
|
||||||
|
|
||||||
An audit release. One new setting you asked for, two naming bugs that only ever
|
An audit release. One new setting you asked for, two naming bugs that only ever
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ went unexercised until a later feature leaned on them.
|
|||||||
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||||
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||||
|
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
|
||||||
|
|
||||||
### Why they took so long to surface
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
|
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
|
||||||
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
|
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
|
||||||
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | 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 |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.9.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",
|
||||||
|
|||||||
+45
-2
@@ -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.0.18
|
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')
|
||||||
@@ -29,7 +29,45 @@ build() {
|
|||||||
bun run build
|
bun run build
|
||||||
# Only the raw binary is needed; packaging is done in package() below so we
|
# Only the raw binary is needed; packaging is done in package() below so we
|
||||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||||
(cd src-tauri && cargo build --release --locked)
|
#
|
||||||
|
# 🔴 `tauri/custom-protocol` is not optional. `tauri build` passes it for you;
|
||||||
|
# a bare `cargo build` does not, and without it Tauri loads the frontend from
|
||||||
|
# `devUrl` rather than the assets embedded from `frontendDist`. The result
|
||||||
|
# builds and installs cleanly and then cannot load its own UI. check() guards
|
||||||
|
# this.
|
||||||
|
(cd src-tauri && cargo build --release --locked --features tauri/custom-protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "$_srcdir"
|
||||||
|
|
||||||
|
# A Tauri binary built without `custom-protocol` does not embed the frontend;
|
||||||
|
# it serves it from `devUrl` (http://localhost:1420) instead. It compiles,
|
||||||
|
# links and installs perfectly, then launches into "Could not connect to
|
||||||
|
# localhost: Connection refused" — which is what this package did for its
|
||||||
|
# entire existence, because `tauri build` adds that feature for you and a bare
|
||||||
|
# `cargo build` does not.
|
||||||
|
#
|
||||||
|
# Test for the *assets*, not for the dev URL: `devUrl` is part of the config
|
||||||
|
# blob that generate_context!() embeds either way, so its presence proves
|
||||||
|
# nothing. A content-hashed filename from the vite build can only be in the
|
||||||
|
# binary if the bundle was embedded — the with-feature binary is ~400 KB
|
||||||
|
# larger for exactly this reason.
|
||||||
|
local _binary="src-tauri/target/release/jellytau"
|
||||||
|
local _asset
|
||||||
|
_asset="$(basename "$(ls -1 build/_app/immutable/entry/*.js | head -n1)")"
|
||||||
|
|
||||||
|
if [ -z "$_asset" ]; then
|
||||||
|
echo "==> ERROR: no frontend build found — 'bun run build' did not produce build/_app." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -qa "$_asset" "$_binary"; then
|
||||||
|
echo "==> ERROR: the frontend bundle is not embedded in the binary." >&2
|
||||||
|
echo " Build with --features tauri/custom-protocol, or the packaged app" >&2
|
||||||
|
echo " will start up unable to load its own UI." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
@@ -42,6 +80,11 @@ package() {
|
|||||||
install -Dm644 "packaging/arch/jellytau.desktop" \
|
install -Dm644 "packaging/arch/jellytau.desktop" \
|
||||||
"$pkgdir/usr/share/applications/jellytau.desktop"
|
"$pkgdir/usr/share/applications/jellytau.desktop"
|
||||||
|
|
||||||
|
# MIT is not in /usr/share/licenses/common, so Arch packaging requires the
|
||||||
|
# licence text to ship with the package.
|
||||||
|
install -Dm644 "LICENSE" \
|
||||||
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
# Icons (hicolor)
|
# Icons (hicolor)
|
||||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ cd "$(dirname "$0")/.."
|
|||||||
|
|
||||||
# Generated, vendored or build-output trees. Their markdown is not authored here
|
# Generated, vendored or build-output trees. Their markdown is not authored here
|
||||||
# and their link targets are not ours to fix.
|
# and their link targets are not ours to fix.
|
||||||
|
#
|
||||||
|
# Only consulted when this is NOT a git checkout — inside one, the tracked-file
|
||||||
|
# list does this job and does not need maintaining. Kept for the tarball case.
|
||||||
EXCLUDES=(
|
EXCLUDES=(
|
||||||
"./node_modules/*"
|
"./node_modules/*"
|
||||||
"./.svelte-kit/*"
|
"./.svelte-kit/*"
|
||||||
@@ -81,14 +84,33 @@ is_generated() {
|
|||||||
|
|
||||||
echo "🔎 Checking relative markdown links resolve to files on disk…"
|
echo "🔎 Checking relative markdown links resolve to files on disk…"
|
||||||
|
|
||||||
# Build the find(1) prune expression from EXCLUDES.
|
# Ask git which markdown files are ours, rather than walking the filesystem.
|
||||||
find_args=(. )
|
#
|
||||||
for pattern in "${EXCLUDES[@]}"; do
|
# This started as a find(1) with a hand-maintained prune list, and that list was
|
||||||
find_args+=(-path "$pattern" -prune -o)
|
# wrong three times in a row: it walked the scratch worktrees under .claude/,
|
||||||
done
|
# then makepkg's vendored cargo registry under packaging/arch/src/ — each time
|
||||||
find_args+=(-name "*.md" -type f -print)
|
# reporting a dependency's broken README as if it were ours. Every one of those
|
||||||
|
# directories is already git-ignored, so the tracked-file list is the exclusion
|
||||||
mapfile -t md_files < <(find "${find_args[@]}" | sort)
|
# rule, and it cannot drift out of date the way EXCLUDES did. It also matches
|
||||||
|
# what this script always claimed to do.
|
||||||
|
#
|
||||||
|
# Untracked-but-not-ignored files are deliberately included: a new doc added in
|
||||||
|
# a working tree should be checked before it is committed, not after.
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
mapfile -t md_files < <(
|
||||||
|
{ git ls-files -z --cached --others --exclude-standard -- '*.md' | tr '\0' '\n'; } \
|
||||||
|
| sed 's|^|./|' | sort -u
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# Not a git checkout (an exported tarball, say): fall back to walking, with
|
||||||
|
# the prune list below as the only defence.
|
||||||
|
find_args=(. )
|
||||||
|
for pattern in "${EXCLUDES[@]}"; do
|
||||||
|
find_args+=(-path "$pattern" -prune -o)
|
||||||
|
done
|
||||||
|
find_args+=(-name "*.md" -type f -print)
|
||||||
|
mapfile -t md_files < <(find "${find_args[@]}" | sort)
|
||||||
|
fi
|
||||||
|
|
||||||
echo " ${#md_files[@]} markdown files"
|
echo " ${#md_files[@]} markdown files"
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ if [ -f src-tauri/Cargo.lock ]; then
|
|||||||
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# PKGBUILD — the Arch package version. Easy to miss because Arch packaging is a
|
||||||
|
# separate path from the tauri bundler, and missing it is exactly the failure
|
||||||
|
# this script exists to prevent: pkgver sat at 0.0.18 while the rest of the tree
|
||||||
|
# had moved on, so `makepkg` produced a package whose version bore no relation
|
||||||
|
# to the source it was built from. `pkgrel` resets to 1 because a new upstream
|
||||||
|
# version starts its packaging revisions over.
|
||||||
|
if [ -f packaging/arch/PKGBUILD ]; then
|
||||||
|
# Arch pkgver may not contain a hyphen (it separates pkgver from pkgrel), so a
|
||||||
|
# dev version like 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234, per the VCS
|
||||||
|
# package guidelines.
|
||||||
|
ARCH_VERSION="$(echo "$VERSION" | sed 's/-\([0-9]*\)-g/.r\1.g/; s/-/_/g')"
|
||||||
|
perl -0pi -e 's/^pkgver=.*$/pkgver='"$ARCH_VERSION"'/m' packaging/arch/PKGBUILD
|
||||||
|
perl -0pi -e 's/^pkgrel=.*$/pkgrel=1/m' packaging/arch/PKGBUILD
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Android versionCode ----------------------------------------------------
|
# --- Android versionCode ----------------------------------------------------
|
||||||
# Only when the generated Android project exists (i.e. after `tauri android
|
# Only when the generated Android project exists (i.e. after `tauri android
|
||||||
# init`); on Linux/Windows jobs there is nothing to stamp.
|
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ function seed(dir: string) {
|
|||||||
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||||
"tauri.android.versionCode=1\n"
|
"tauri.android.versionCode=1\n"
|
||||||
);
|
);
|
||||||
|
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "packaging", "arch", "PKGBUILD"),
|
||||||
|
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(version: string, dir = tmp) {
|
function run(version: string, dir = tmp) {
|
||||||
@@ -178,4 +183,29 @@ describe("set-version.sh", () => {
|
|||||||
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Arch package is built by makepkg, not the tauri bundler, so its version
|
||||||
|
// lives in a file the rest of the release path never touches. It sat at
|
||||||
|
// 0.0.18 while the tree was on 0.8.x — makepkg happily produced a package
|
||||||
|
// whose version bore no relation to the source it was built from, which is
|
||||||
|
// the exact failure this script was written to prevent.
|
||||||
|
describe("PKGBUILD", () => {
|
||||||
|
it("stamps pkgver and resets pkgrel", () => {
|
||||||
|
run("0.9.0");
|
||||||
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
||||||
|
expect(pkgbuild).toMatch(/^pkgver=0\.9\.0$/m);
|
||||||
|
// A new upstream version starts its packaging revisions over.
|
||||||
|
expect(pkgbuild).toMatch(/^pkgrel=1$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts a dev version into a pkgver Arch accepts", () => {
|
||||||
|
// pkgver may not contain a hyphen — it is the pkgver/pkgrel separator.
|
||||||
|
run("0.9.0-3-gabc1234");
|
||||||
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
||||||
|
const match = pkgbuild.match(/^pkgver=(.*)$/m);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
expect(match![1]).not.toContain("-");
|
||||||
|
expect(match![1]).toBe("0.9.0.r3.gabc1234");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.9.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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.9.0",
|
"version": "0.9.1",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import { createRotationTimer, type RotationTimer } from "./heroRotation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
let { items, autoRotate = true, interval = 6000 }: Props = $props();
|
let { items, autoRotate = true, interval = 6000 }: Props = $props();
|
||||||
|
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
let intervalId: number | null = null;
|
let rotation: RotationTimer | null = null;
|
||||||
|
|
||||||
// Touch/swipe state
|
// Touch/swipe state
|
||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
@@ -70,8 +71,22 @@
|
|||||||
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual navigation (swipe, arrows, dots) restarts the countdown, so the
|
||||||
|
// banner always waits a full interval after the last change instead of
|
||||||
|
// firing whatever was left of the previous one.
|
||||||
|
function showNext() {
|
||||||
|
next();
|
||||||
|
rotation?.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPrev() {
|
||||||
|
prev();
|
||||||
|
rotation?.restart();
|
||||||
|
}
|
||||||
|
|
||||||
function goToIndex(idx: number) {
|
function goToIndex(idx: number) {
|
||||||
currentIndex = idx;
|
currentIndex = idx;
|
||||||
|
rotation?.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch/swipe handlers
|
// Touch/swipe handlers
|
||||||
@@ -96,10 +111,10 @@
|
|||||||
if (Math.abs(diff) > swipeThreshold) {
|
if (Math.abs(diff) > swipeThreshold) {
|
||||||
if (diff > 0) {
|
if (diff > 0) {
|
||||||
// Swiped left - go to next
|
// Swiped left - go to next
|
||||||
next();
|
showNext();
|
||||||
} else {
|
} else {
|
||||||
// Swiped right - go to previous
|
// Swiped right - go to previous
|
||||||
prev();
|
showPrev();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +125,12 @@
|
|||||||
// Auto-rotate logic
|
// Auto-rotate logic
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoRotate && items.length > 1) {
|
if (autoRotate && items.length > 1) {
|
||||||
intervalId = window.setInterval(next, interval);
|
const timer = createRotationTimer(interval, next);
|
||||||
|
rotation = timer;
|
||||||
|
timer.restart();
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalId) clearInterval(intervalId);
|
timer.stop();
|
||||||
|
rotation = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,7 +237,7 @@
|
|||||||
|
|
||||||
<!-- Swipe Indicators (Desktop hover) -->
|
<!-- Swipe Indicators (Desktop hover) -->
|
||||||
<button
|
<button
|
||||||
onclick={prev}
|
onclick={showPrev}
|
||||||
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||||
aria-label="Previous item"
|
aria-label="Previous item"
|
||||||
>
|
>
|
||||||
@@ -229,7 +247,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={next}
|
onclick={showNext}
|
||||||
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||||
aria-label="Next item"
|
aria-label="Next item"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// TRACES: UR-034 | DR-038 | UT-207
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createRotationTimer } from "./heroRotation";
|
||||||
|
|
||||||
|
describe("hero banner rotation timer", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("advances once per interval while running", () => {
|
||||||
|
const onElapse = vi.fn();
|
||||||
|
const timer = createRotationTimer(6000, onElapse);
|
||||||
|
timer.restart();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
expect(onElapse).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing until started", () => {
|
||||||
|
const onElapse = vi.fn();
|
||||||
|
createRotationTimer(6000, onElapse);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(onElapse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bug: a manual swipe/click left the interval running, so the banner
|
||||||
|
// rotated again almost immediately instead of waiting a full interval.
|
||||||
|
it("restarts the countdown from now, not from the last auto-advance", () => {
|
||||||
|
const onElapse = vi.fn();
|
||||||
|
const timer = createRotationTimer(6000, onElapse);
|
||||||
|
timer.restart();
|
||||||
|
|
||||||
|
// 5.5s in the user swipes — the timer must restart from that moment.
|
||||||
|
vi.advanceTimersByTime(5500);
|
||||||
|
timer.restart();
|
||||||
|
|
||||||
|
// The remaining 500ms of the old countdown must NOT fire.
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(onElapse).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A full interval after the swipe, it advances.
|
||||||
|
vi.advanceTimersByTime(5500);
|
||||||
|
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stack timers when restarted repeatedly", () => {
|
||||||
|
const onElapse = vi.fn();
|
||||||
|
const timer = createRotationTimer(1000, onElapse);
|
||||||
|
timer.restart();
|
||||||
|
timer.restart();
|
||||||
|
timer.restart();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops firing after stop()", () => {
|
||||||
|
const onElapse = vi.fn();
|
||||||
|
const timer = createRotationTimer(1000, onElapse);
|
||||||
|
timer.restart();
|
||||||
|
timer.stop();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(onElapse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports whether it is running", () => {
|
||||||
|
const timer = createRotationTimer(1000, () => {});
|
||||||
|
expect(timer.isRunning()).toBe(false);
|
||||||
|
timer.restart();
|
||||||
|
expect(timer.isRunning()).toBe(true);
|
||||||
|
timer.stop();
|
||||||
|
expect(timer.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Auto-rotation timer for the home-screen hero banner.
|
||||||
|
//
|
||||||
|
// Extracted from HeroBanner.svelte so it can be unit-tested: a manual swipe or
|
||||||
|
// dot/arrow click must restart the countdown from that moment. The old code
|
||||||
|
// installed one bare setInterval and left it running, so swiping late in an
|
||||||
|
// interval made the banner jump to the next item almost immediately.
|
||||||
|
//
|
||||||
|
// TRACES: UR-034 | DR-038 | UT-207
|
||||||
|
|
||||||
|
export interface RotationTimer {
|
||||||
|
/** (Re)start the countdown from now, replacing any pending tick. */
|
||||||
|
restart(): void;
|
||||||
|
/** Cancel the countdown. */
|
||||||
|
stop(): void;
|
||||||
|
isRunning(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a repeating timer that calls `onElapse` every `interval` ms once
|
||||||
|
* started. Restarting is idempotent — there is never more than one live timer.
|
||||||
|
*/
|
||||||
|
export function createRotationTimer(interval: number, onElapse: () => void): RotationTimer {
|
||||||
|
let handle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (handle !== null) {
|
||||||
|
clearInterval(handle);
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
restart() {
|
||||||
|
stop();
|
||||||
|
handle = setInterval(onElapse, interval);
|
||||||
|
},
|
||||||
|
stop,
|
||||||
|
isRunning: () => handle !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user