fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP

Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
This commit is contained in:
2026-08-15 16:26:31 +02:00
parent 50934e2ac6
commit 9f5f57cba4
42 changed files with 1548 additions and 139 deletions
@@ -0,0 +1,67 @@
package com.dtourolle.jellytau
import android.app.Activity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* Hides and restores the Android system bars for full-screen video.
*
* TRACES: UR-066 | DR-157
*
* ## Why the web layer cannot do this
*
* `document.documentElement.requestFullscreen()` is the only fullscreen control
* the frontend has, and inside an Android WebView it does nothing to the
* *Activity*: it expands the fullscreen element within the web viewport and
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` — which
* MainActivity must call, and which SDK 36 makes non-optional — the WebView
* already spans the whole window, so "fullscreen" was a no-op that changed
* nothing on screen while the status bar and navigation/gesture bar stayed
* painted over the video.
*
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
* which is reachable only from native code. Hence this bridge.
*
* ## Behaviour
*
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
* a swipe from either edge brings them back *transiently* — over the video,
* auto-hiding again — rather than permanently resizing the window mid-playback.
* That is the standard behaviour for immersive video and keeps the system's own
* back/home gestures reachable.
*
* [exit] restores them. It must be called when leaving fullscreen **and** when
* the player is torn down, or the bars stay hidden on the library screens behind
* it.
*
* Both must run on the main thread; the callers in MainActivity post them there,
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
*
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
* republishes them and the player's control layer stops reserving space it no
* longer needs.
*/
object ImmersiveModeBridge {
private fun controller(activity: Activity): WindowInsetsControllerCompat =
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
fun enter(activity: Activity) {
controller(activity).apply {
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
android.util.Log.d("ImmersiveMode", "system bars hidden")
}
/** Restore the system bars. Safe to call when they are already showing. */
fun exit(activity: Activity) {
controller(activity).show(WindowInsetsCompat.Type.systemBars())
android.util.Log.d("ImmersiveMode", "system bars restored")
}
}
@@ -246,6 +246,19 @@ class MainActivity : TauriActivity() {
fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off — so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -325,6 +338,28 @@ class MainActivity : TauriActivity() {
}, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
// inside a WebView cannot touch the Activity window, so without this the
// status and navigation bars stayed painted over full-screen video.
webView.addJavascriptInterface(object : Any() {
/** Hide the system bars for full-screen playback. */
@JavascriptInterface
fun enter() {
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
}
/** Restore the system bars on leaving fullscreen or the player. */
@JavascriptInterface
fun exit() {
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
}
/** Whether native immersive mode exists (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidImmersive")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
// Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
@@ -46,6 +46,62 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "native video path check failed", e)
false
}
/**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app.
@@ -64,15 +120,10 @@ object PictureInPictureManager {
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
return try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
}
/**
@@ -125,32 +176,47 @@ object PictureInPictureManager {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return null
null
}
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width
val height = surface.height
if (width <= 0 || height <= 0) return null
val surface = player?.getSurfaceView()
if (surface != null && surface.width > 0 && surface.height > 0) {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
val ratio = width.toDouble() / height.toDouble()
val minRatio = 1.0 / 2.39
val maxRatio = 2.39
val clamped = ratio.coerceIn(minRatio, maxRatio)
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
}
// Scale to integers; Rational(width, height) directly can overflow for
// large surfaces, and the clamped value may not match the raw pixels.
/**
* Clamp a ratio to the range Android accepts and express it as a [Rational].
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content. Scaled to integers because
* `Rational(width, height)` can overflow for large surfaces, and the clamped
* value may not match the raw pixels anyway.
*/
private fun clampedRatio(ratio: Double): Rational {
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
return Rational((clamped * 1000).toInt(), 1000)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -222,11 +288,20 @@ object PictureInPictureManager {
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
hideWebView(activity)
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
registerReceiver(activity)
dispatchWebEvent(activity, "jellytau-pip-entered")
} else {
unregisterReceiver(activity)
showWebView()
dispatchWebEvent(activity, "jellytau-pip-exited")
// The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized.
try {
@@ -237,6 +312,23 @@ object PictureInPictureManager {
}
}
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
}
}
private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView)
if (webView == null) {
@@ -264,14 +356,30 @@ object PictureInPictureManager {
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
}
// Swap the button to reflect the new state.
updatePipActions(activity)
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("seek:$positionSeconds")
}
// media3 seeks by more routes than seekTo(long), and the ones below
// reach the *real* ExoPlayer if they are not overridden — bypassing
// Rust entirely and operating on the handoff stream's relative
// timeline. That is the same mechanism as the truncation bug, reached
// by a different door.
//
// seekToDefaultPosition is deliberately swallowed rather than
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
// player and then calls play(); on a handoff stream the seek lands at
// stream zero — the point the screen was locked at — which is exactly
// the reported jump-back. Sending "seek:0.0" instead would be worse
// still, restarting the whole episode. Rust already owns what "play
// after the stream ended" means (truncation recovery, or advancing to
// the next episode), and the play() that follows reaches it, so the
// right move here is to not move at all.
//
// TRACES: UR-040, UR-005 | DR-159
override fun seekToDefaultPosition() {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
)
}
override fun seekToDefaultPosition(mediaItemIndex: Int) {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
)
}
// `currentPosition` is ExoPlayer's own, so it is relative during a
// handoff; the base makes the target absolute, which is what Rust
// expects from every command on this boundary.
override fun seekBack() {
val target =
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun seekForward() {
val target =
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun stop() {
nativeOnMediaCommand("stop")
}
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen
// MediaSession. During a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
// position RELATIVE to that point (starting at 0). The metadata duration,
// however, is the full absolute length — so without this base the scrubber
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
// position via setPositionOffset(); 0 for normal playback.
private var positionOffsetMs: Long = 0L
// The handoff base (ms): during a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
// starts at 0 *there* and every position it reports is relative to it. This
// is the number that converts one back to a real position on the episode.
//
// It is deliberately read, not applied, here. This used to be a display-only
// correction added at the two setPlaybackState calls below, which left every
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
// seeks — working in the relative timeline while treating it as absolute, each
// crossing silently losing exactly `base` seconds. The conversion now happens
// once, in JellyTauPlayer's position tick, so everything downstream of it
// speaks the episode's timeline; applying it again here would double-count.
//
// TRACES: UR-040 | DR-159
@Volatile
var handoffBaseMs: Long = 0L
private set
/**
* Set the base position offset (seconds) applied to lockscreen positions.
* Called by the native layer when entering/exiting a background-audio handoff.
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
* Set the handoff base (seconds). Called by the native layer when entering or
* leaving a background-audio handoff; 0 clears it for normal playback, where
* ExoPlayer's position is already absolute.
*/
fun setPositionOffset(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
fun setHandoffBase(offsetSeconds: Double) {
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
}
/**
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state (position made absolute via the base offset).
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Already absolute: this call comes from Rust, whose stored position is on
// the episode's timeline. (DR-159)
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
@@ -337,15 +395,15 @@ class JellyTauPlaybackService : MediaSessionService() {
* notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback.
*
* @param position Position in milliseconds
* @param position Absolute position in milliseconds, on the item's own
* timeline — the caller has already applied [handoffBaseMs].
* @param isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
@@ -1030,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) {
if (exoPlayer.isPlaying) {
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
// THE boundary between the two timelines, and the only place
// the conversion happens.
//
// During a background-audio handoff the stream is requested
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
// is that point and everything it reports is relative to it.
// The base used to be added only where a position was *shown*
// (the lockscreen scrubber), leaving progress reports to
// Jellyfin, the frontend and the truncation maths all working
// in the relative timeline while treating it as absolute —
// each crossing losing exactly `base` seconds, which is why the
// jump-back distance varied with where the screen was locked.
// Shifting once, here, means every consumer downstream speaks
// the episode's timeline and none of them needs to know a
// handoff happened.
//
// The duration is shifted with it, so position and duration
// stay on the same timeline — the stream's own length is only
// what remains after the handoff point.
//
// TRACES: UR-040 | DR-159
val service = JellyTauPlaybackService.getInstance()
val baseMs = service?.handoffBaseMs ?: 0L
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
val position = positionMs / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
val duration =
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
service?.updatePlaybackPosition(positionMs, true)
}
delay(POSITION_UPDATE_INTERVAL_MS)
}