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
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.4.8"
version = "0.5.3"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.4.8"
version = "0.5.3"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -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)
}
+19 -15
View File
@@ -770,22 +770,23 @@ pub async fn player_enter_background_audio(
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
// Back to foreground playback: the lockscreen scrubber is absolute again.
let _ = crate::player::set_lockscreen_position_offset(0.0);
let controller = player.0.lock().await;
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Zero after a backend-driven
// episode advance, whose stream already starts at its own zero.
let base = controller.exit_background_audio();
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position();
// Read the position BEFORE clearing either base. The position tick applies the
// base natively, so a tick landing between "base cleared" and "position read"
// would hand back a relative position — the whole bug, reintroduced at the one
// moment it matters most. Capturing into a `let` before stop() is also the
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159)
let absolute = controller.position();
// Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0);
controller.exit_background_audio();
controller.stop().map_err(|e| e.to_string())?;
let absolute = base + relative;
info!(
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
base, relative, absolute
"player_exit_background_audio: resuming the video at {:.1}s",
absolute
);
Ok(absolute)
}
@@ -1207,9 +1208,12 @@ pub async fn player_seek(
let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?;
} else {
// Local playback
// Local playback. seek_absolute, not seek: the position came from the UI,
// which shows the whole item, so during a background-audio handoff it has
// to be resolved against the episode's timeline rather than the handoff
// stream's. (DR-159)
let controller = player.0.lock().await;
controller.seek(position).map_err(|e| e.to_string())?;
controller.seek_absolute(position).await?;
}
let controller = player.0.lock().await;
+80
View File
@@ -867,6 +867,86 @@ pub async fn storage_mark_played(
}
}
/// Set the watched flag locally for an item **and everything inside it**.
///
/// This backs the watched toggle, and is deliberately separate from
/// [`storage_mark_played`] — which reports a single track/episode finishing and
/// increments `play_count` — because the toggle has two directions and applies
/// to containers.
///
/// The recursion is what makes the toggle honest offline. Jellyfin applies
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
/// online the server fixes up the children on the next read; with no server to
/// ask, marking a season watched would otherwise tick the season and leave every
/// episode inside it unwatched. Targets are drawn from `items` by the same link
/// columns the rest of the offline layer uses, so an id that is not cached
/// selects nothing and the statement is a no-op rather than a foreign-key error.
///
/// Un-marking clears the resume position too, matching the server, so an item
/// un-marked offline does not come back offering to resume from a position it is
/// no longer meant to have.
///
/// `pending_sync = 1` hands the rows to the sync drain.
///
/// TRACES: UR-073 | DR-158
#[tauri::command]
#[specta::specta]
pub async fn storage_set_watched(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
watched: bool,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// The item itself plus its descendants: a season's episodes reach it by
// season_id, a series' by series_id, its seasons by parent_id, an album's
// tracks by album_id.
let targets = "SELECT id FROM items
WHERE id = ? OR parent_id = ? OR album_id = ?
OR season_id = ? OR series_id = ?";
let sql = if watched {
format!(
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_played = 1,
play_count = MAX(user_data.play_count, 1),
last_played_at = CURRENT_TIMESTAMP,
pending_sync = 1"
)
} else {
format!(
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_played = 0,
play_count = 0,
playback_position_ticks = 0,
pending_sync = 1"
)
};
let query = Query::with_params(
sql,
vec![
QueryParam::String(user_id),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get playback progress for an item
#[tauri::command]
#[specta::specta]
+59
View File
@@ -52,6 +52,12 @@ pub enum QueuedOp {
MarkPlayed {
item_id: String,
},
/// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
/// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
/// item un-marked offline does not come back carrying a stale position.
MarkUnplayed {
item_id: String,
},
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
/// (DR-120). Supported so a row written by an older build still lands.
Favorite {
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
position_ticks: ticks(),
}),
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
"mark_favorite" => Ok(QueuedOp::Favorite {
item_id,
is_favorite: true,
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
position_ticks,
} => self.report_playback_stopped(item_id, *position_ticks).await,
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
QueuedOp::Favorite {
item_id,
is_favorite,
@@ -1031,4 +1039,55 @@ mod tests {
assert!(parse_queued_op("mark_played", None, None).is_err());
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
}
/// Un-marking watched queues like marking watched does, so the toggle works
/// in both directions while the server is unreachable rather than only one.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[test]
fn test_parse_accepts_mark_unplayed() {
assert_eq!(
parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
QueuedOp::MarkUnplayed {
item_id: "ep1".to_string()
},
);
assert!(parse_queued_op("mark_unplayed", None, None).is_err());
}
/// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
/// mark-unplayed, which also zeroes the resume position, so a series returns
/// to "never watched" rather than keeping a stale position.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[tokio::test]
async fn test_drain_pushes_mark_unplayed() {
let db = test_db();
seed(
&db,
&[(
"u1",
"mark_unplayed",
"ep9",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
)],
)
.await;
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::MarkUnplayed {
item_id: "ep9".to_string()
}],
);
assert_eq!(report.pushed, 1);
assert_eq!(report.remaining, 0);
}
}
+24 -7
View File
@@ -265,6 +265,7 @@ use commands::{
storage_save_user,
storage_search_items,
storage_set_active_user,
storage_set_watched,
storage_toggle_favorite,
storage_update_playback_context,
storage_update_playback_progress,
@@ -424,6 +425,28 @@ impl MediaSessionHandler {
/// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) {
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
// whole episode — and resolving it during a background-audio handoff means
// re-opening the stream, which is async. So it runs on the runtime and,
// critically, is handled *before* the blocking lock below: taking that
// guard and then spawning a task that waits for the same mutex would
// deadlock the media session. (DR-159)
if let Some(raw) = command.strip_prefix("seek:") {
match raw.parse::<f64>() {
Ok(position) => {
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
if let Err(e) = controller.seek_absolute(position).await {
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
}
});
}
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
}
return;
}
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
@@ -433,13 +456,6 @@ impl MediaSessionHandler {
"next" => controller.next(),
"previous" => controller.previous(),
"stop" => controller.stop(),
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(pos) => controller.seek(pos),
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown command: {}", command);
Ok(())
@@ -789,6 +805,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_set_watched,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
+6 -4
View File
@@ -1474,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
/// Set the background-audio handoff base (seconds) on the playback service.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
/// place the relative handoff timeline is converted to the episode's own — see
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
@@ -1525,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
env.call_method(
&service_obj,
"setPositionOffset",
"setHandoffBase",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
Ok(())
}
+130 -11
View File
@@ -754,12 +754,50 @@ impl PlayerController {
}
}
/// Seek to a position in seconds
/// Seek to a position in seconds, **on the player's own timeline**.
///
/// During a background-audio handoff that timeline is relative to the handoff
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
/// timeline and is what every caller outside the player itself means.
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.seek(position)
}
/// Seek to an **absolute** position on the item's own timeline.
///
/// This is the boundary every outside seek comes through — the UI, the
/// lockscreen scrubber, a headset gesture — because all of them are looking
/// at the whole episode, not at whatever fragment of it the player happens to
/// be streaming.
///
/// Outside a background-audio handoff the two timelines are the same and this
/// is an ordinary seek. Inside one they differ by the handoff base, and the
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
/// clamped seek lands at stream zero, which is the handoff point. That is the
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
/// re-opening the URL at the new position, which is exactly what the
/// truncation recovery already does, so it shares `resume_stream_at`.
///
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
let rebuild = self.is_background_audio_active() && {
let queue = self.queue.lock_safe();
queue
.current()
.map(Self::is_audio_only_video)
.unwrap_or(false)
};
if rebuild {
return self.resume_stream_at(position.max(0.0)).await;
}
self.seek(position).map_err(|e| e.to_string())
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock_safe().set_volume(volume)
@@ -1384,8 +1422,10 @@ impl PlayerController {
return None;
}
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
// Already absolute: the Android position tick shifts by the handoff base
// before anything sees the value, so adding it again here would
// double-count it. (DR-159)
let absolute = self.position().max(0.0);
match self.stream_resume.lock_safe().allow_attempt(absolute) {
Some(attempt) => Some((absolute, attempt)),
@@ -1425,8 +1465,8 @@ impl PlayerController {
}
current.duration
};
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
// Already absolute — see claim_stream_resume. (DR-159)
let absolute = self.position().max(0.0);
// Only spend a resume attempt once the runtime says this really was cut
// short — a genuine end must stay a genuine end.
@@ -3604,10 +3644,88 @@ mod tests {
}
}
/// The handoff stream's timeline starts at the handoff position, so the
/// player reports a *relative* position. The runtime it is compared against
/// is absolute — the base has to be added back, or every handoff looks like a
/// truncation.
/// A seek arriving during a background-audio handoff is **absolute** — the
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
/// 25:00 of the episode, not 25:00 into the handoff stream.
///
/// The handoff stream cannot be seeked at all (a chunked, length-less
/// transcode), so honouring it means re-opening the URL at the new position,
/// exactly as the truncation recovery does. Passing the number through to
/// ExoPlayer instead — which is what used to happen — asked a stream that
/// cannot seek to jump past its own end, and a clamped seek lands at stream
/// zero: the handoff point.
///
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
#[tokio::test]
async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Handed off 20 minutes in, so the stream's zero is 1200s.
controller.enter_background_audio(1200.0);
// The viewer scrubs the lockscreen to 25:00 absolute.
controller.seek_absolute(1490.0).await.unwrap();
let url = {
let queue = controller.queue();
let queue = queue.lock_safe();
match &queue.current().unwrap().source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
other => panic!("expected a remote source, got {:?}", other),
}
};
assert!(
url.contains(&format!(
"StartTimeTicks={}",
(1490.0 * 10_000_000.0) as i64
)),
"the stream must be re-opened at the absolute position; got {}",
url
);
assert_eq!(
*controller.background_audio_base.lock_safe(),
1490.0,
"the re-opened stream's zero is the position it was opened at, or \
every later reading is off by the difference"
);
}
/// Outside a handoff there is no base and nothing to re-open: an absolute
/// seek is just a seek, and must not be turned into a stream rebuild.
///
/// TRACES: UR-005 | DR-159 | UT-155
#[tokio::test]
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.seek_absolute(300.0).await.unwrap();
assert_eq!(controller.position(), 300.0);
assert_eq!(
*controller.background_audio_base.lock_safe(),
0.0,
"an ordinary seek must not invent a handoff base"
);
}
/// The truncation check compares the position against the item's runtime, so
/// both must be on the same timeline.
///
/// They now are by construction: the Android position tick shifts by the
/// handoff base before anything sees the value, so what the player reports is
/// already a position on the episode. The base is therefore *not* added here —
/// doing so would double-count it and make the last minute of a handoff look
/// like a truncation. What the mock backend holds is what the real one would
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
#[tokio::test]
async fn test_truncated_check_uses_the_absolute_position() {
let controller = PlayerController::default();
@@ -3616,9 +3734,10 @@ mod tests {
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Handed off at 24:00; the stream then played its last 56 seconds out.
// Handed off at 24:00; the stream then played its last 56 seconds out, so
// the player reports 24:56 of the episode.
controller.set_background_audio_base(1440.0);
controller.seek(56.0).unwrap();
controller.seek(1496.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.4.8",
"version": "0.5.3",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",