Compare commits

..
5 Commits
Author SHA1 Message Date
dtourolle ad05dcd484 chore(release): bump version to v0.11.3
Build & Release / Create Release (push) Blocked by required conditions
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m42s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m24s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Build & Release / Run Tests (push) Successful in 15m5s
Build & Release / Build Linux (push) Waiting to run
Build & Release / Build Windows (push) Waiting to run
Build & Release / Build Android (push) Waiting to run
Stamped with scripts/set-version.sh, which also picks up packaging/arch/PKGBUILD
— stale at 0.10.1, since the last two bumps edited the version files by hand.
2026-08-25 22:47:40 +02:00
dtourolle 8a2b484e36 fix(library): keep the A-Z jump strip above the mini player
The last few letters of the A-Z index sat behind the mini player and bottom
nav, where they could not be tapped — most visible on an album's track list or
the music library, since that is where audio is usually already playing.

AlphabetScrollBar sized itself as window.innerHeight minus a hardcoded
bottomGap: 5rem, 7rem or 11rem, chosen by platform and by whether the mini
player was showing. That arithmetic dates from when the mini player and bottom
nav were position: fixed overlays. They have been in-flow flex siblings below
the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is*
the top of the mini player and can simply be measured.

The guess was also short on every device with a navigation or gesture bar,
because --safe-bottom is padded inside BottomUi (DR-112) and no guess knew
about it. Measured against a 800px viewport: the strip overran the scrollport
by ~45px with the nav alone, ~18px with the mini player and ~50px in remote
mode, burying one to three letters.

The floor is now the nearest scrollable ancestor's bottom edge. That ancestor
is resolved by computed overflow-y rather than closest("main"): the root shell
scrolls in a plain div, and a miss fell back to the viewport silently, which
would reinstate the bug on any route outside /library. Observing that scroller
for resize is also what re-measures when the mini player appears, so the
component no longer subscribes to player or platform stores at all.

The floor rule is extracted to alphabetStrip.ts so it can be tested; the three
overlap cases above fail against the old arithmetic and pass against the new.

TRACES: UR-007 | DR-262 | UT-235, UT-236, UT-237
2026-08-25 22:47:08 +02:00
dtourolle 1d6487774c fix(android): subtitles on the picture, not on a black bar
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m35s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 40s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Every subtitle line arrived in an opaque black box wide enough to sit across
the picture. That box is what Android hands back when the viewer has set no
captioning preferences: SubtitleView.setUserDefaultStyle() reads the system
style and falls back to media3's DEFAULT, which is white on opaque black.

Dropping the box is not the same as replacing the style. Someone who has
configured captions in accessibility settings has said something specific about
colour, typeface and edges, and overriding all of it to remove a background
would answer a question they did not ask. Their style is kept and only the two
colours that paint a box — background and window — are cleared.

A style specifying no edge gets a black outline, since without a box the text
must supply its own contrast or it is unreadable over a bright scene. One that
already names an edge keeps it: that viewer has said how they want their
captions separated from the picture.

Compiles and packages, but NOT yet seen on a device — the tablet was
disconnected before it could be deployed, so the requirement is recorded as
"Done (pending device verification)" and this must not be tagged into a release
until someone has looked at it.

TRACES: UR-020 | DR-261
2026-08-25 20:12:50 +02:00
dtourolle 1aec38b760 chore(release): bump version to v0.11.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 15m6s
Build & Release / Build Linux (push) Successful in 21m8s
Build & Release / Build Windows (push) Successful in 15m55s
Build & Release / Build Android (push) Successful in 31m16s
Build & Release / Create Release (push) Successful in 39s
2026-08-23 22:00:44 +02:00
dtourolle 0187ee179e fix(android): draw the subtitles the player already decodes
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s
Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer
decodes subtitles and delivers them to a listener; it draws none itself. A
PlayerView would supply the view that does, but native video here is a bare
TextureView the WebView composites over — so nothing held the cues and every
one was decoded, delivered and dropped. There was no onCues, no TextOutput and
no SubtitleView anywhere in the app, and media3-ui was not even a dependency.

The gap was invisible for as long as every subtitle URL 404ed: with no text
track to select there was never a cue to lose, so fixing the URL is what
exposed it.

media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of
the content view — above the video, still below the WebView, so cues sit over
the picture and under the app's own controls. It is fitted to the letterboxed
video rect rather than the screen, so cues stay inside the picture and follow
it on rotation, and is removed by the same teardown that detaches the surface
(the defect DR-184 exists to prevent).

Verified on a device: track selected with no "Invalid subtitle track index",
SubtitleView attached at the fitted rect per the live view hierarchy, and cues
legible on screen during playback.

TRACES: UR-020, UR-003 | DR-260
2026-08-23 20:20:01 +02:00
14 changed files with 376 additions and 49 deletions
+33
View File
@@ -9,6 +9,39 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.11.3
### 🐛 Fixes
- **The A-Z jump strip no longer runs under the mini player.** On a long
alphabetical list — an album's tracks, the music library — the last few
letters sat behind the mini player and bottom nav, where they could not be
tapped. The strip was sizing itself against the window minus a hardcoded guess
at how tall those bars are, which stopped being true when they became part of
the normal layout instead of floating over it, and was always short by the
height of your phone's navigation bar. It now measures the list it belongs to
and stops exactly where that list stops, whatever is below. (UR-007 → DR-262)
- **Subtitles sit on the picture instead of on a black bar.** Every line arrived
in an opaque box, which is what Android hands back when no captioning
preferences have been set. The box is gone; the text keeps its own outline so
it stays readable over a bright scene. If you *have* set up captions in
Android's accessibility settings, your colours, typeface and edges are still
used — only the background is cleared. (UR-020 → DR-261)
## v0.11.2
### 🐛 Fixes
- **Subtitles appear on screen on Android.** Turning one on did nothing, even
once they were loading again: the player hands finished subtitles to a view
that draws them, and on the native Android path there was no such view — so
every cue was decoded, delivered and dropped. There is one now, sitting over
the picture and under the controls, following the video's shape when the
screen turns. This was hidden behind the loading failure fixed in v0.11.1;
with nothing to select, there had never been a cue to lose. (UR-020, UR-003 →
DR-260)
## v0.11.1
Four fixes. Two had been present since the first release and were found on a
+7 -1
View File
@@ -453,6 +453,9 @@ Internal architecture, components, and application logic.
| DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done |
| DR-258 | An audio-track change is honoured by **re-opening the stream** when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — but the native path only ever called `setAudioTrack(n)`, which indexes ExoPlayer's audio track *groups*. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: ExoPlayer held one audio track while the menu listed every track in the file, so every selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. `determine_audio_track_switch_strategy` now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer, not the UI's: the native path has no `<video>` element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning | Player | UR-021, UR-005 | Done |
| DR-259 | Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. Since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a *mock helper* duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release | Repository | UR-020 | Done |
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
| DR-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 |
---
@@ -469,7 +472,7 @@ Internal architecture, components, and application logic.
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
@@ -779,6 +782,9 @@ Internal architecture, components, and application logic.
| UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done |
| UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done |
| UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done |
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
### Integration Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.1",
"version": "0.11.3",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.10.1
pkgver=0.11.3
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
+1 -1
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.1"
version = "0.11.3"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.11.1"
version = "0.11.3"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+4
View File
@@ -142,6 +142,10 @@ dependencies {
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-common:1.5.0")
// SubtitleView. ExoPlayer delivers cues to a listener and draws none of them
// itself: without a view to hand them to, a selected subtitle track renders
// nowhere. See JellyTauPlayer.onCues. (DR-260)
implementation("androidx.media3:media3-ui:1.5.0")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
@@ -15,6 +15,9 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
object VideoOverlayManager {
private var attachedSurfaceView: TextureView? = null
/** The cue view attached alongside it, removed by the same teardown. */
private var attachedSubtitleView: androidx.media3.ui.SubtitleView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null
@@ -56,6 +59,25 @@ object VideoOverlayManager {
contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView
// Subtitles go directly above the video and still below the WebView:
// visible through the transparent page, and under the app's own
// controls rather than over them. Index 1 is what makes that
// sandwich — the same reason the video is pinned to index 0.
// TRACES: UR-020, UR-003 | DR-260
player.getSubtitleView()?.let { subtitles ->
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
contentView.addView(
subtitles,
1,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
attachedSubtitleView = subtitles
android.util.Log.d("VideoOverlayManager", "Subtitle view attached above the video")
}
// Re-fit the video whenever the content view's bounds change (e.g. on
// device rotation) so the video is letterboxed to fit instead of being
// stretched/cropped by the MATCH_PARENT surface.
@@ -101,6 +123,10 @@ object VideoOverlayManager {
fun detachVideoSurface() {
try {
removeLayoutListener()
attachedSubtitleView?.let { subtitles ->
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
attachedSubtitleView = null
}
attachedSurfaceView?.let { surfaceView ->
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
attachedSurfaceView = null
@@ -233,6 +233,23 @@ class JellyTauPlayer(private val appContext: Context) {
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/**
* Draws subtitle cues over the picture.
*
* ExoPlayer decodes subtitles and *delivers* them to a listener; it draws
* none of them itself. A `PlayerView` would supply this view, but native
* video here is a bare TextureView the WebView composites over, so nothing
* was holding the cues and a selected subtitle track rendered nowhere. That
* gap was invisible for as long as every subtitle URL 404ed (DR-259) with
* no text track to select, there was never a cue to drop.
*
* Sized and positioned to the video rect rather than the screen, so cues sit
* inside the picture rather than in a letterbox bar.
*
* TRACES: UR-020, UR-003 | DR-260
*/
private var subtitleView: androidx.media3.ui.SubtitleView? = null
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -539,6 +556,19 @@ class JellyTauPlayer(private val appContext: Context) {
}
}
/**
* Hand each cue group to the view that draws it.
*
* Fires with an empty list when subtitles are turned off or the
* track has nothing to show at this moment, which is what clears the
* previous cue so this is the whole of both showing and hiding.
*
* TRACES: UR-020 | DR-260
*/
override fun onCues(cueGroup: androidx.media3.common.text.CueGroup) {
subtitleView?.setCues(cueGroup.cues)
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
@@ -1294,9 +1324,85 @@ class JellyTauPlayer(private val appContext: Context) {
}
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
}
if (subtitleView == null) {
subtitleView = androidx.media3.ui.SubtitleView(appContext).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Text over the picture, not a black bar across it. (DR-261)
setStyle(captionStyle())
setUserDefaultTextSize()
}
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
}
return videoView!!.hashCode()
}
/**
* The caption style to draw cues in: the viewer's own, with the background
* taken out.
*
* `setUserDefaultStyle()` reads Android's captioning preferences and falls
* back to media3's `DEFAULT` when the viewer has set none and that
* default is white on **opaque black**, which is what put a black box under
* every line, wide enough to sit across the picture.
*
* Dropping the box is not the same as replacing the style. A viewer who has
* configured captions in accessibility settings has said something specific
* about colour, typeface and edges, and overriding all of that to get a
* transparent background would be answering a question they did not ask. So
* their style is kept and only the two colours that paint a box
* background and window are cleared.
*
* With no box the text supplies its own contrast or it is unreadable over a
* bright scene, so a style that asked for no edge gets a black outline. One
* that already specifies an edge keeps it: that viewer has already said how
* they want their captions separated from the picture.
*
* TRACES: UR-020 | DR-261
*/
private fun captionStyle(): androidx.media3.ui.CaptionStyleCompat {
val base = try {
val captioning = appContext.getSystemService(Context.CAPTIONING_SERVICE)
as? android.view.accessibility.CaptioningManager
if (captioning != null && captioning.isEnabled) {
androidx.media3.ui.CaptionStyleCompat.createFromCaptionStyle(captioning.userStyle)
} else {
androidx.media3.ui.CaptionStyleCompat.DEFAULT
}
} catch (e: Exception) {
// A captioning service that refuses to answer is not a reason to
// draw nothing; fall back to the same default media3 would use.
android.util.Log.w("JellyTauPlayer", "Captioning preferences unavailable", e)
androidx.media3.ui.CaptionStyleCompat.DEFAULT
}
val needsOwnEdge = base.edgeType == androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_NONE
return androidx.media3.ui.CaptionStyleCompat(
base.foregroundColor,
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
if (needsOwnEdge) {
androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_OUTLINE
} else {
base.edgeType
},
if (needsOwnEdge) android.graphics.Color.BLACK else base.edgeColor,
base.typeface
)
}
/**
* The view that draws subtitle cues, for VideoOverlayManager to attach
* directly above the video and below the WebView. Null before the first
* video load.
*/
fun getSubtitleView(): androidx.media3.ui.SubtitleView? {
return subtitleView
}
/**
* Get the video view instance (for VideoOverlayManager).
* Returns null if none has been created yet.
@@ -1413,6 +1519,20 @@ class JellyTauPlayer(private val appContext: Context) {
lp.height = targetH
view.layoutParams = lp
view.requestLayout()
// Cues belong to the picture, not to the screen: matching the
// letterboxed rect keeps them off the black bars and moves them
// with the video on rotation. (DR-260)
subtitleView?.let { subs ->
val slp = subs.layoutParams
if (slp is FrameLayout.LayoutParams) {
slp.gravity = android.view.Gravity.CENTER
}
slp.width = targetW
slp.height = targetH
subs.layoutParams = slp
subs.requestLayout()
}
android.util.Log.d(
"JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
@@ -1442,6 +1562,10 @@ class JellyTauPlayer(private val appContext: Context) {
exoPlayer.clearVideoSurface()
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
videoView = null
// Released with the surface it belonged to; detachVideoSurface
// removes it from the hierarchy, and keeping the reference would
// leave the next video's cues going to an orphaned view.
subtitleView = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.1",
"version": "0.11.3",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -1,4 +1,4 @@
<!-- TRACES: UR-007 | DR-007 -->
<!-- TRACES: UR-007 | DR-007, DR-262 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
@@ -6,8 +6,14 @@
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*
* The strip's floor is the scroll container's own bottom edge, measured — not
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
* that distinction is the whole bug (DR-262).
*/
import { stripHeightFor } from "./alphabetStrip";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
@@ -16,43 +22,45 @@
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
let { availableLetters, onJump }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
function measure() {
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
/**
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
* by tag name: the library routes scroll in a `<main>`, but the root shell
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
* to the viewport — which is exactly the too-tall strip this replaced.
*/
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
node = node.parentElement;
}
return null;
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
function measure() {
if (!container) return;
const scroller = nearestScroller(container);
stripHeight = stripHeightFor({
stripTop: container.getBoundingClientRect().top,
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
viewportHeight: window.innerHeight,
});
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const scroller = nearestScroller(container);
// Observing the scroller is what makes the mini player showing or hiding
// re-measure: it is an in-flow sibling, so the scroller resizes when it
// appears. No store subscription and no platform guess needed.
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
@@ -64,15 +72,6 @@
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
@@ -6,8 +6,6 @@
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -259,11 +257,6 @@
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
</script>
<div class="space-y-6">
@@ -363,7 +356,7 @@
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
</div>
{/if}
</div>
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
/**
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
* alphabet could not be tapped.
*
* The strip used to size itself against `window.innerHeight` minus a hardcoded
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
* whether the mini player was showing). Those bars stopped being fixed overlays
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
* has no relationship to the real stack and it is short on any device with a
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
*
* The invariant every case below asserts: the strip must end at or above the
* scroller's own bottom edge, which is exactly the top of the mini player.
*
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
*/
/** 800px-tall phone viewport; the library scroller starts 120px down. */
const VIEWPORT = 800;
const STRIP_TOP = 120;
/** Measured heights of the real bottom UI, in CSS px. */
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
function bounds(bottomUiHeight: number): StripHeightInput {
return {
stripTop: STRIP_TOP,
scrollerBottom: VIEWPORT - bottomUiHeight,
viewportHeight: VIEWPORT,
};
}
describe("stripHeightFor", () => {
it("keeps the last letter above the bottom nav when nothing is playing", () => {
const input = bounds(NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("keeps the last letter above the mini player while audio plays", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("survives the taller mini player of remote mode", () => {
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("still fills the space it does have, rather than stopping short", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const available = input.scrollerBottom! - STRIP_TOP;
// Within one letter's worth of the space available (letters are ~16px).
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
});
it("falls back to the viewport when the strip has no scroll container", () => {
const height = stripHeightFor({
stripTop: STRIP_TOP,
scrollerBottom: null,
viewportHeight: VIEWPORT,
});
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
});
it("never returns a negative height when the strip is scrolled past the floor", () => {
const height = stripHeightFor({
stripTop: 900,
scrollerBottom: 600,
viewportHeight: VIEWPORT,
});
expect(height).toBe(0);
});
});
@@ -0,0 +1,54 @@
/**
* Pure geometry for the A-Z jump strip (see AlphabetScrollBar.svelte).
*
* The strip stretches from wherever it sits down to a floor, and the floor is
* the whole question: get it wrong and the tail of the alphabet renders past
* the bottom of the scroller, under the mini player / bottom nav, where it
* cannot be tapped.
*
* The floor is the scroller's own bottom edge — never the viewport's. The strip
* used to size itself as `window.innerHeight` minus a hardcoded guess at the
* bars' height (5rem/7rem/11rem by platform and mini-player visibility), which
* dates from when those bars were `position: fixed` overlays. They are in-flow
* flex siblings below the scroller now (see BottomUi.svelte), so the scroller's
* bottom edge *is* the top of the mini player, measured exactly, every frame
* and the guess was short on every device with a navigation/gesture bar,
* because `--safe-bottom` is padded inside BottomUi and the guess never knew
* about it.
*
* Extracted from the component so the floor rule is unit-testable the
* component only supplies measurements.
*
* TRACES: UR-007 | DR-007, DR-262
*/
/**
* Breathing room left under the last letter, in px. Mirrors the `top-2` sticky
* offset at the other end so the strip sits symmetrically in the scrollport.
*/
export const STRIP_BOTTOM_GAP = 8;
export interface StripHeightInput {
/** Viewport-relative top of the strip container (`getBoundingClientRect().top`). */
stripTop: number;
/**
* Viewport-relative bottom edge of the scroll container the strip lives in,
* or `null` when the strip has no scrollable ancestor to measure.
*/
scrollerBottom: number | null;
/** Viewport height — the fallback floor when there is no scroll container. */
viewportHeight: number;
/** Override for {@link STRIP_BOTTOM_GAP}, in px. */
gap?: number;
}
/** How tall the A-Z strip may be without running under the bottom bars. */
export function stripHeightFor({
stripTop,
scrollerBottom,
viewportHeight,
gap = STRIP_BOTTOM_GAP,
}: StripHeightInput): number {
const floor = scrollerBottom ?? viewportHeight;
return Math.max(0, floor - gap - stripTop);
}