On Android, dragging or tapping the progress bar moved the thumb but
playback stayed where it was. Two separate defects, both touch-only,
which is why the mouse-driven scrub tests never caught either.
1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that
land on a control, but handleTouchMove kept running. It measures
against touchStartX/Y, which that early return leaves at the PREVIOUS
gesture's values, so a seek-bar drag produced a huge bogus vertical
delta: read as a brightness swipe, it dimmed the screen to the 0.3
floor and fired a spurious play/pause "correction" mid-drag. A gesture
is now latched at touchstart (playerGestureActive) and touchmove
ignores anything unlatched — re-checking the move target cannot
recover a start point that was never recorded.
2. Commit signal. The seek was committed only from `change`, which
Android's WebView does not reliably fire for a touch interaction on a
range input, so the thumb moved to the tapped position and no seek
ever ran. touchend/mouseup now commit too; `input` arms a one-shot
latch so whichever release signal arrives first commits and the other
is a no-op. seekRelative shares the same commitSeek entry point
instead of fabricating a synthetic change event.
Tests drive the slider with real touch events (UT-089, UT-090) and fail
against the pre-fix component.
Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.
The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.
Replace the deferral with immediate action — there are only first and
second taps:
1st tap: toggle play/pause
2nd tap: seek, then toggle play/pause again
The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.
Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.
A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.
UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.
el.paused flips transiently while an element buffers or settles a seek.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.
The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.
Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.
Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
An on-device test build compiled all four ABIs (arm64/arm/x86/x86_64),
so three of the four Rust compiles were thrown away. That dominated the
build time when iterating against a connected phone.
--device resolves the attached device's ABI via adb and targets just
that triple; --abi <target> selects one explicitly; ABI= works as an
env var. Default behaviour is unchanged (all four), since a
distributable universal APK genuinely needs them.
bun run android:build:device
bun run android:build:release:device
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.
Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.
This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.
Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
Seeking near the end of a transcoded video locked the player into a
stall/pause loop: unpausing or skipping bounced straight back to paused.
Both seek paths clamped the target to exactly `duration`. hls.js then
requested the segment whose start time lies *past* the end of the media
(a 6330.324s item asks for segment 1055, starting at 6336.33s). Jellyfin
never produces that segment, the fetch times out, and the gap-controller
stalls forever at the last buffered position — retrying ~1x/second and
firing an endless stream of AbortErrors as play() lands mid-nudge.
Clamp strictly inside the media instead, keeping one segment length
(6s) of margin, floored at 0 so short media still seeks to the start.
The seek-bar drag path needed this too: its range input `max` is the
duration itself, so dragging fully right produced the same dead target.
Also bumps the requirement-count fixture for the new DR-095 row.
check:boundary passed on the very leak it was written for. The pattern was
anchored to `includeItemTypes:` at the query site, so searchScope.ts
assigning the same array to a named const and dereferencing it one
indirection away was invisible — through every green CI run.
The check now matches an array literal naming two or more Jellyfin item
types anywhere in src/, catching a const, a Record value, a function
return, and an inline query alike. Deliberate limits kept: two adjacent
literals required (single-type presentation stays legal), string literals
required (item.type === "Audio" is display logic), explicit type list
(so ["High","Low"] produces no noise).
Verified all five cases: reintroducing the original SCOPE_ITEM_TYPES
fails; a new const ["Movie","Series"] fails; the same array in a
.test.ts passes; itemType: "Movie" / item.type === / ["High","Low"] pass;
a 5th allowlist entry fails on the new cap.
Allowlist 1→3 entries, capped at 4 so the next exception forces a
conversation rather than a one-line append:
- GenericMediaListPage: grid styling over a self-declared itemType —
presentation, changes only with a UI redesign.
- DownloadedBrowse: borderline, leans domain (the container set grows
when Jellyfin adds a container type). Allowlisted with a TODO for a
backend MediaItem.isContainer flag.
The header now names what the check still cannot see — run-time-built
sets, types split across variables, switch/|| taxonomy — and CLAUDE.md
states that a green check:boundary is not proof. That matters given this
check passed on its own founding violation for months.
Also: both gates wired into test-all.sh, which called `bun run test`
without --run and would have hung in watch mode. Corrected the Dockerfile
comment describing the Windows toolchain as mingw/GNU — it is MSVC via
cargo-xwin (GNU cannot bundle NSIS from Linux).
All three shared one root cause: an unscoped `grep -r src-tauri/`, which
walks ~40GB of target/ build artifacts.
- check-req-coverage.sh: also read README.md, which has held zero
requirement rows since they moved to docs/requirements.md. Reported
"Total Requirements: 1", zeros in every category, then printed
"All requirements have implementations!" — the opposite of a warning,
from an empty result set.
- check-test-coverage.sh: hung indefinitely, no output at all.
- find-req-implementations.sh: same hang.
None was referenced by CI, package.json, or the docs.
They were salvageable — the greps just needed scoping — but they read an
undocumented `@req:` / `@req-test:` tag convention parallel to `TRACES:`
(146 and 76 occurrences, described in no doc; CLAUDE.md documents only
TRACES). Repairing them would re-establish the second source of truth
that let "1 requirement" and "211 requirements" coexist unnoticed.
extract-traces.ts is now the single owner of coverage reporting.
The existing @req:/@req-test: comments are left in place: harmless as
prose, several encode useful test intent, and stripping 222 comments is a
large diff with no functional gain. They are simply no longer read.
The coverage gate divided traced counts by hardcoded literals (UR/39,
IR/24, DR/48, JA/3, TOTAL_REQS=114) that had fallen out of date as
requirements grew to 211. It reported 158% coverage — JA alone printed
800% — so the 50% threshold was mathematically unreachable and the job
could not fail. Coverage could have collapsed to 30% and CI would still
have printed a green tick.
Real coverage is 86%. The number was fine; the gate was dead.
extract-traces.ts now owns both sides of the fraction:
- countDefinedRequirements() counts an ID only where it leads a markdown
table row, ignoring the "Traces To" column and prose. IDs are
deduplicated because requirements.md lists every UR twice (§1
definition + §3 matrix), which would otherwise report UR as 121/61.
- computeCoverage() uses the intersection of traced and defined IDs, so
a TRACES comment naming a deleted or typo'd requirement is reported as
`orphaned` rather than inflating the ratio past 100%. UT/IT test
identifiers are excluded as a separate taxonomy.
- CI reads .coverage.percent and fails on <50% or >100%; a >100% reading
is now a hard error rather than the condition that hid this bug.
- New `bun run traces:coverage` runs the same computation locally.
- scripts/ added to the scan roots — the coverage tool was invisible to
the matrix it generates.
Tests written first (15, over fixtures so they don't drift as
requirements are added). vitest include widened to scripts/** so build
tooling is covered by the normal suite.
Verified empirically rather than by inspection: forcing the threshold to
99% fails; adding a requirement lowers coverage 86%→85%; a TRACES: DR-999
lands in `orphaned` without changing `covered`.
traceability-ci.md documented the same stale numbers and would have let
the broken arithmetic be reconstructed — replaced with a pointer to the
live command.
Adds Docker-based packaging for Linux desktop (deb/rpm), Arch
(.pkg.tar.zst via makepkg), and Windows. Windows cross-compiles from
Linux via the official Tauri path — the x86_64-pc-windows-msvc target
driven by cargo-xwin — and produces an NSIS installer (nsis via
tauri.conf.json targets, since the CLI rejects --bundles nsis on a Linux
host). Verified end to end: builds jellytau.exe + jellytau_x64-setup.exe.
Unifies everything on one registry builder image (Dockerfile.builder):
Android SDK/NDK, rpm/file, clang(+clang-cl)/lld/llvm/nsis, cargo-xwin and
the msvc target. Packaging tools sit in a trailing layer so tool changes
rebuild in ~1min instead of ~15. Desktop stages are thin FROM
${BUILDER_IMAGE} layers; Arch uses a separate archlinux image.
CLAUDE.md: CI must install no system toolchains — everything lives in the
image. Bumps version 0.0.18 -> 0.1.0 (Windows support + webview audio +
equalizer).
TRACES: UR-003, UR-005 | DR-004
Grep-based tripwire flagging multi-type includeItemTypes literals in the
Svelte frontend — the machine-detectable signature of the item-type
taxonomy leaking into presentation. Wire it into the Gitea build-and-test
workflow and a check:boundary package script. Motivated by
docs/specs/scoped-search-boundary.md.
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
Add PiP for native (ExoPlayer) video on Android. Video renders into a
SurfaceView behind the WebView, so PiP is driven by the Activity shrinking
into a floating window rather than the HTML5 PiP API (which WebKitGTK does
not implement, hence Android-only).
- PictureInPictureManager.kt: enter PiP with the video's aspect ratio
(clamped to the 1:2.39-2.39:1 range Android accepts, outside which it
throws), plus a play/pause RemoteAction. Hides the WebView while in PiP -
it is opaque and sits above the surface, so it would otherwise occlude the
video entirely - and re-fits the surface on exit.
- MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged,
and an AndroidPictureInPicture JS interface following the existing
AndroidAudioFocus pattern.
- pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when
the native bridge reports support.
- proguard: keep rules for @JavascriptInterface methods, which are only
referenced from JS and would be stripped in minified release builds.
Casting needs no special handling: canEnterPip() checks natively that a
local video surface is attached and playing, which a remote session lacks.
While wiring the manifest, found that three tracked files under
src-tauri/android/ were never reaching any build. Gradle reads only
gen/android/app/src/main/, and sync-android-sources.sh did not copy them:
- src/main/AndroidManifest.xml was a partial <application> fragment written
as if Tauri merged it. It does not - there is no manifest-merger hook
here, so its hardwareAccelerated flag never reached an APK. Promoted to
the complete authoritative manifest (folding in that flag) and synced.
- src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows)
was never copied; the sync only globbed mipmap-*. Now synced.
- build.gradle.kts was a leftover com.android.library module config with
stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at
1.5.0. Deleted.
Verified: merged manifest now carries hardwareAccelerated,
supportsPictureInPicture, resizeableActivity and the density configChange;
themes.xml compiles into merged resources; Kotlin builds warning-free;
svelte-check clean; 537 frontend tests pass.
Not verified: PiP behaviour on a device, and the release keep rules against
a minified build. assembleUniversalDebug cannot complete in this
environment - the Rust step wants a dev-server addr file that only exists
under `tauri android dev`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Standardize on bun: remove package-lock.json, add packageManager field,
gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
and slim README down to a project intro + docs index
- Fix internal references to the moved files
- Convert music category buttons from <button> to native <a> links for better Android compatibility
- Convert artist/album nested buttons in TrackList to <a> links to fix HTML validation issues
- Add event handlers with proper stopPropagation to maintain click behavior
- Increase library overview card sizes from medium to large (50% bigger)
- Increase thumbnail sizes in list view from 10x10 to 16x16
- Add console logging for debugging click events on mobile
- Remove preventDefault() handlers that were blocking Android touch events
These changes resolve navigation issues on Android devices where buttons weren't responding to taps. Native <a> links provide better cross-platform compatibility and allow SvelteKit to handle navigation more reliably.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>