Compare commits

..
18 Commits
Author SHA1 Message Date
dtourolle 75014ee00f Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
2026-07-01 23:49:51 +02:00
dtourolleandClaude Opus 4.8 342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
  controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
  auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
  progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
  mirroring the MPV backend.

Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
  track over the runTimeTicks estimate, so pausing no longer clobbers the
  slider's max to 0 when runTimeTicks is missing.

Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
  discriminator, so a video started via player_play_item (no Jellyfin `type`,
  mediaType "video") no longer surfaces in the audio mini player.

Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
  distinguishing tails (episode numbers, suffixes) stay visible.

Adds regression tests for the duration and mini-player fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:52:27 +02:00
dtourolle dcee342c47 Jray mugshots of actors shown
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 14m55s
Traceability Validation / Check Requirement Traces (push) Successful in 51s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 26m44s
2026-06-28 21:09:06 +02:00
dtourolle 78f5cd9db9 Fix playback regression
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-06-28 21:07:00 +02:00
dtourolle 0eae81ec59 Add JRay support
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m59s
Traceability Validation / Check Requirement Traces (push) Successful in 1m48s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:38:58 +02:00
dtourolle 8eae4ae253 layout improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 9m2s
Traceability Validation / Check Requirement Traces (push) Successful in 2m30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:14:17 +02:00
dtourolle ef7be645b3 Merge pull request 'fix/lockscreen-mediasession-sync' (#7) from fix/lockscreen-mediasession-sync into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m47s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m40s
Reviewed-on: #7
2026-06-27 21:57:22 +00:00
dtourolle b9249f72e9 rescale logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m27s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-27 23:56:36 +02:00
dtourolleandClaude Opus 4.8 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.

- Make MediaSessionCompat the single source of truth; route all transport
  commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
  via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
  and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
  session poller (works while the screen is locked, unlike WebView timers) via
  a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
  prev/seek to the remote Jellyfin session; Stop while casting emits
  RemoteDisconnectRequested, which the frontend handles by transferring to local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:26 +02:00
dtourolle 345bd0730c Merge pull request 'feat/plugin-channel-support' (#6) from feat/plugin-channel-support into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m40s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m27s
Build & Release / Run Tests (push) Successful in 4m6s
Build & Release / Build Linux (push) Successful in 16m11s
Build & Release / Build Android (push) Successful in 18m47s
Build & Release / Create Release (push) Successful in 13s
Reviewed-on: #6
2026-06-27 15:52:39 +00:00
dtourolle e1e50d51e0 Use different app logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m1s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m52s
Build & Release / Build Linux (push) Successful in 16m22s
Build & Release / Build Android (push) Successful in 19m13s
Build & Release / Create Release (push) Successful in 10s
2026-06-27 17:43:08 +02:00
dtourolle 7d7f27aa10 feat(library and playback): Support for serverside channel plugins and hls streaming 2026-06-27 17:25:57 +02:00
dtourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00
dtourolle ff8f35084b Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m54s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m38s
Build & Release / Build Linux (push) Successful in 16m2s
Build & Release / Build Android (push) Successful in 18m54s
Build & Release / Create Release (push) Successful in 11s
Reviewed-on: #5
2026-06-25 21:52:13 +00:00
dtourolle 4634ed595c fix(remote playback): Move audio between remote players
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m37s
2026-06-25 23:39:01 +02:00
dtourolle 6836ce79c8 fix(Remote playback): kludge to scrub after stream move
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m18s
2026-06-25 21:31:39 +02:00
dtourolle 2811e1b7ca fix: several small fixes
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m26s
2026-06-25 20:02:01 +02:00
dtourolle 1836615dc0 feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00
118 changed files with 4787 additions and 647 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
# JellyTau <h1 align="center">
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
JellyTau
</h1>
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

@@ -15,6 +15,8 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
object VideoOverlayManager { object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null private var attachedSurfaceView: SurfaceView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null
/** /**
* Attach the video SurfaceView to the Activity's content view. * Attach the video SurfaceView to the Activity's content view.
@@ -51,6 +53,23 @@ object VideoOverlayManager {
contentView.addView(surfaceView, 0, layoutParams) contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView attachedSurfaceView = surfaceView
// 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.
removeLayoutListener()
val listener = android.view.View.OnLayoutChangeListener {
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
player.fitSurfaceToScreen()
}
}
contentView.addOnLayoutChangeListener(listener)
contentLayoutListener = listener
listenerContentView = contentView
// Fit once now that the surface is attached and the parent is sized.
player.fitSurfaceToScreen()
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy") android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e) android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
@@ -64,6 +83,7 @@ object VideoOverlayManager {
*/ */
fun detachVideoSurface(activity: Activity) { fun detachVideoSurface(activity: Activity) {
try { try {
removeLayoutListener()
attachedSurfaceView?.let { surfaceView -> attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content) val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView) contentView.removeView(surfaceView)
@@ -83,4 +103,12 @@ object VideoOverlayManager {
fun isVideoSurfaceAttached(): Boolean { fun isVideoSurfaceAttached(): Boolean {
return attachedSurfaceView != null return attachedSurfaceView != null
} }
private fun removeLayoutListener() {
contentLayoutListener?.let { listener ->
listenerContentView?.removeOnLayoutChangeListener(listener)
}
contentLayoutListener = null
listenerContentView = null
}
} }
@@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
val jellyTauPlayer = JellyTauPlayer.getInstance() val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer() val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands // Wrap the ExoPlayer to intercept commands from Media3 controllers
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
// session rather than the MediaSessionCompat).
//
// We do NOT execute on ExoPlayer directly here. Every transport command
// is routed to Rust via nativeOnMediaCommand, which is the single decision
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
// would double-handle local commands and incorrectly drive the local
// player while casting.
wrappedPlayer = object : ForwardingPlayer(exoPlayer) { wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() { override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play") nativeOnMediaCommand("play")
} }
override fun pause() { override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause") nativeOnMediaCommand("pause")
} }
override fun seekToNext() { override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next") nativeOnMediaCommand("next")
} }
override fun seekToPrevious() { override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous") nativeOnMediaCommand("previous")
} }
override fun seekTo(positionMs: Long) { override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0 val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds") nativeOnMediaCommand("seek:$positionSeconds")
} }
override fun stop() { override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
} }
} }
@@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
) )
isActive = true isActive = true
// Set callback to handle lock screen button presses // Set callback to handle lock screen button presses.
//
// All transport commands are routed through Rust via nativeOnMediaCommand
// rather than directly to ExoPlayer. Rust is the single decision point:
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
// the command to the remote Jellyfin session. This keeps the lockscreen
// working identically for both, and avoids the ExoPlayer-only behaviour
// that left remote playback uncontrollable from the lockscreen.
setCallback(object : MediaSessionCompat.Callback() { setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play() nativeOnMediaCommand("play")
} }
override fun onPause() { override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause() nativeOnMediaCommand("pause")
} }
override fun onSkipToNext() { override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext() nativeOnMediaCommand("next")
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious() nativeOnMediaCommand("previous")
} }
override fun onStop() { override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop() nativeOnMediaCommand("stop")
} }
override fun onSeekTo(position: Long) { override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position) val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
} }
}) })
} }
@@ -253,9 +252,19 @@ class JellyTauPlaybackService : MediaSessionService() {
.build() .build()
} }
// Last-known metadata/state, retained so lightweight position ticks can
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
// and notification. Kept in sync by updateMediaMetadata().
private var lastTitle: String = ""
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
/** /**
* Update the MediaSession metadata and playback state. * Update the MediaSession metadata and playback state, plus the notification.
* This updates both the MediaSession and the notification. *
* Call this when the track or play/pause state changes. For frequent position
* updates during playback, use [updatePlaybackPosition] instead, which is much
* cheaper (no metadata rebuild, no notification rebuild).
*/ */
fun updateMediaMetadata( fun updateMediaMetadata(
title: String, title: String,
@@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
) { ) {
val session = mediaSessionCompat ?: return val session = mediaSessionCompat ?: return
lastTitle = title
lastArtist = artist
lastIsPlaying = isPlaying
// Update MediaSession metadata // Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder() val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
@@ -280,7 +293,42 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build()) session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state // Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder() session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update only the playback position (and play/pause state) on the MediaSession.
*
* This is the cheap path used for the periodic (250ms) position ticks: it
* refreshes the lockscreen scrubber without rebuilding metadata or the
* 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 isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
}
}
/**
* Build a PlaybackStateCompat with the standard transport actions.
*
* The reported playback speed is 1.0 while playing and 0.0 while paused so
* Android does not extrapolate the position past a paused track.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
return PlaybackStateCompat.Builder()
.setActions( .setActions(
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
@@ -292,13 +340,9 @@ class JellyTauPlaybackService : MediaSessionService() {
.setState( .setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position, position,
1.0f if (isPlaying) 1.0f else 0.0f
) )
.build()
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
} }
/** /**
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */ /** Current media ID being played */
private var currentMediaId: String? = null private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */ /** Current media metadata for notification updates */
private var currentTitle: String = "" private var currentTitle: String = ""
private var currentArtist: String = "" private var currentArtist: String = ""
@@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
/** SurfaceView for video playback */ /** SurfaceView for video playback */
private var surfaceView: SurfaceView? = null private var surfaceView: SurfaceView? = null
private var surfaceHolder: SurfaceHolder? = null private var surfaceHolder: SurfaceHolder? = 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
private var currentMediaType: MediaType = MediaType.AUDIO private var currentMediaType: MediaType = MediaType.AUDIO
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
@@ -208,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed // Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended") android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates() stopPositionUpdates()
// Only notify the backend once per loaded media. ExoPlayer
// can re-enter STATE_ENDED, which would double-count things
// like the sleep-timer episode counter.
if (!endedNotified) {
endedNotified = true
nativeOnPlaybackEnded() nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...") android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@@ -245,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
} }
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}") android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
// Apply pixel aspect ratio so anamorphic content isn't distorted
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
videoHeight = videoSize.height
fitSurfaceToScreen()
} }
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
@@ -322,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) { fun load(url: String, mediaId: String) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare() exoPlayer.prepare()
@@ -552,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
) { ) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates // Store metadata for notification updates
currentTitle = title currentTitle = title
@@ -741,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { if (exoPlayer.isPlaying) {
val position = exoPlayer.currentPosition / 1000.0 val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
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 / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration") android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(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)
} }
delay(POSITION_UPDATE_INTERVAL_MS) delay(POSITION_UPDATE_INTERVAL_MS)
} }
@@ -848,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
/** /**
* Resize the video surface (for orientation changes). * Resize the video surface (for orientation changes).
*
* Re-fits the surface to the screen preserving the video's aspect ratio so
* nothing is cropped when the device rotates.
*/ */
fun resizeSurface(width: Int, height: Int) { fun resizeSurface(width: Int, height: Int) {
fitSurfaceToScreen()
}
/**
* Size the video SurfaceView so the video fits entirely inside its parent
* (the full-screen content view) while preserving aspect ratio (letterbox/
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
* video to the surface bounds, which crops the bottom on rotation.
*/
fun fitSurfaceToScreen() {
mainHandler.post { mainHandler.post {
surfaceView?.let { view -> val view = surfaceView ?: return@post
view.layoutParams = view.layoutParams.apply { val parent = view.parent as? ViewGroup
this.width = width // Available area: prefer the parent's measured size, fall back to the screen.
this.height = height val availW = parent?.width?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.widthPixels
val availH = parent?.height?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.heightPixels
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
return@post
} }
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
val viewAspect = availW.toFloat() / availH.toFloat()
val targetW: Int
val targetH: Int
if (videoAspect > viewAspect) {
// Video is wider than the screen → fit width, letterbox top/bottom
targetW = availW
targetH = (availW / videoAspect).toInt()
} else {
// Video is taller than the screen → fit height, pillarbox sides
targetH = availH
targetW = (availH * videoAspect).toInt()
}
val lp = view.layoutParams
// FrameLayout child: center the fitted surface within the full-screen parent.
if (lp is FrameLayout.LayoutParams) {
lp.gravity = android.view.Gravity.CENTER
}
lp.width = targetW
lp.height = targetH
view.layoutParams = lp
view.requestLayout() view.requestLayout()
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}") android.util.Log.d(
} "JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
)
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -1087,7 +1087,7 @@ pub async fn enqueue_video_downloads(
/// (FIFO within a priority), registers each, flips it to `downloading`, and /// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure, /// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement. /// so the queue drains itself without any frontend involvement.
async fn pump_download_queue( pub(crate) async fn pump_download_queue(
app: tauri::AppHandle, app: tauri::AppHandle,
db_service: Arc<crate::storage::db_service::RusqliteService>, db_service: Arc<crate::storage::db_service::RusqliteService>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>, active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
+21 -3
View File
@@ -41,12 +41,30 @@ pub fn playback_mode_is_transferring(
pub async fn playback_mode_transfer_to_remote( pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String, session_id: String,
position: Option<f64>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!( log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}", "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id session_id,
position
); );
manager.0.transfer_to_remote(session_id).await manager.0.transfer_to_remote(session_id, position).await
}
/// Set the transferring flag on the playback mode manager.
///
/// Used by the frontend remote->local flow to mark the whole two-step sequence
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
/// casting back to the remote session it's leaving. Always pair `true` with a
/// later `false` (including on error) so the flag can't stick.
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_set_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
transferring: bool,
) -> Result<(), String> {
manager.0.set_transferring(transferring);
Ok(())
} }
/// Transfer playback from remote session back to local device /// Transfer playback from remote session back to local device
+341 -21
View File
@@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize, pub start_index: usize,
pub shuffle: bool, pub shuffle: bool,
pub context: PlayTracksContext, pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
} }
/// Context information for track playback /// Context information for track playback
@@ -374,6 +378,65 @@ pub(super) async fn check_for_local_download(
} }
} }
/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
/// while it plays (preloaded upcoming tracks) — or that existed before the
/// connection dropped — would otherwise keep streaming. Called before advancing
/// so the next track always prefers the on-disk copy.
///
/// Returns the number of items switched to a local source.
pub(super) async fn refresh_queue_local_sources(
controller: &PlayerController,
db: &DatabaseWrapper,
) -> Result<usize, String> {
// Collect remote item IDs first; the queue lock must not be held across awaits.
let remote_ids: Vec<String> = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.items()
.iter()
.filter_map(|item| match &item.source {
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id.clone()),
_ => None,
})
.collect()
};
if remote_ids.is_empty() {
return Ok(0);
}
let mut local_paths: Vec<(String, String)> = Vec::new();
for id in remote_ids {
if let Some(path) = check_for_local_download(db, &id).await? {
local_paths.push((id, path));
}
}
if local_paths.is_empty() {
return Ok(0);
}
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
let mut switched = 0;
for item in queue_lock.items_mut() {
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
info!("[Player] Switching queued track {} to local download: {}", id, path);
item.source = MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(id.clone()),
};
switched += 1;
}
}
}
Ok(switched)
}
/// Play a single media item (audio or video) /// Play a single media item (audio or video)
/// ///
/// Accepts a PlayItemRequest with all optional fields properly defaulted. /// Accepts a PlayItemRequest with all optional fields properly defaulted.
@@ -675,6 +738,7 @@ pub async fn player_next(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
debug!("[player_next] Command called from frontend"); debug!("[player_next] Command called from frontend");
@@ -693,6 +757,10 @@ pub async fn player_next(
} else { } else {
// Local playback // Local playback
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_next] Failed to refresh local sources: {}", e);
}
controller.next().map_err(|e| e.to_string())?; controller.next().map_err(|e| e.to_string())?;
controller.emit_queue_changed(); controller.emit_queue_changed();
@@ -728,6 +796,7 @@ pub async fn player_previous(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
// Check if we're in remote mode // Check if we're in remote mode
let mode = playback_mode.0.get_mode(); let mode = playback_mode.0.get_mode();
@@ -744,6 +813,10 @@ pub async fn player_previous(
} else { } else {
// Local playback // Local playback
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_previous] Failed to refresh local sources: {}", e);
}
controller.previous().map_err(|e| e.to_string())?; controller.previous().map_err(|e| e.to_string())?;
controller.emit_queue_changed(); controller.emit_queue_changed();
@@ -1245,6 +1318,59 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
} }
} }
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index = adjusted_index
.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue /// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command] #[tauri::command]
@@ -1254,6 +1380,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayAlbumTrackRequest, request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@@ -1376,11 +1503,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play the queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
} else {
controller controller
.play_queue(media_items, start_index) .play_queue(media_items, start_index)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer // Set the queue context for remote transfer
{ {
@@ -1422,6 +1562,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayTracksRequest, request: PlayTracksRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@@ -1529,10 +1670,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.play_queue(media_items, request.start_index)
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} else {
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context // Set queue context
{ {
@@ -1574,12 +1738,23 @@ pub async fn player_preload_upcoming(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>, smart_cache: State<'_, SmartCacheWrapper>,
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
app: tauri::AppHandle,
user_id: String, user_id: String,
_download_base_path: String, _download_base_path: String,
) -> Result<PreloadResult, String> { ) -> Result<PreloadResult, String> {
let db_service = { // The pump only starts rows that carry both a stream URL and a target dir,
// so resolve the same storage root the user-initiated download paths use
// (storage_get_path = the database's parent directory).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service()) let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
}; };
// Get cache settings // Get cache settings
@@ -1617,10 +1792,12 @@ pub async fn player_preload_upcoming(
// Process each upcoming item // Process each upcoming item
for item in upcoming_items { for item in upcoming_items {
// Only process items with Remote source (not already local) // Only process items with Remote source (not already local). The
let jellyfin_id = match &item.source { // source already carries the resolved stream URL — reuse it so the
MediaSource::Remote { jellyfin_item_id, .. } => { // pump can start the download without any extra resolution step.
jellyfin_item_id.clone() let (jellyfin_id, stream_url) = match &item.source {
MediaSource::Remote { jellyfin_item_id, stream_url } => {
(jellyfin_item_id.clone(), stream_url.clone())
} }
MediaSource::Local { .. } => { MediaSource::Local { .. } => {
already_downloaded += 1; already_downloaded += 1;
@@ -1632,9 +1809,13 @@ pub async fn player_preload_upcoming(
} }
}; };
// Check if already downloaded // Check if already downloaded or actively in flight. Stale pending rows
// without a stream URL are NOT skipped here — the upsert below heals
// them so the pump can finally start them.
let query = Query::with_params( let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? AND status IN ('completed', 'downloading', 'pending') LIMIT 1", "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
AND (status IN ('completed', 'downloading')
OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
vec![ vec![
QueryParam::String(jellyfin_id.clone()), QueryParam::String(jellyfin_id.clone()),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.clone()),
@@ -1651,14 +1832,29 @@ pub async fn player_preload_upcoming(
continue; continue;
} }
// Queue for download with low priority (preload priority = -100) // Queue for download with low priority (preload priority = -100) so
let file_path = format!("{}/{}.mp3", sanitize_filename(&item.album.clone().unwrap_or_default()), sanitize_filename(&item.title)); // user-initiated downloads always win a pump slot first.
let album_dir = item
.album
.as_deref()
.filter(|a| !a.is_empty())
.unwrap_or("Unknown Album");
let file_path = format!(
"downloads/{}/{}.mp3",
sanitize_filename(album_dir),
sanitize_filename(&item.title)
);
// Insert download record with preload priority // Insert with the stream URL + target dir the pump needs to start it.
// On conflict, heal pre-existing rows that were queued without a URL
// (they could never start) instead of leaving them stuck.
let insert_query = Query::with_params( let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name) "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?) VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
ON CONFLICT(item_id, user_id) DO NOTHING", ON CONFLICT(item_id, user_id) DO UPDATE SET
stream_url = excluded.stream_url,
target_dir = excluded.target_dir
WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
vec![ vec![
QueryParam::String(jellyfin_id), QueryParam::String(jellyfin_id),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.clone()),
@@ -1666,6 +1862,8 @@ pub async fn player_preload_upcoming(
QueryParam::String(item.title.clone()), QueryParam::String(item.title.clone()),
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
], ],
); );
@@ -1685,6 +1883,16 @@ pub async fn player_preload_upcoming(
} }
} }
// Kick the pump so the queued preloads actually start; without this they'd
// only begin once some other download activity pumps the queue.
if queued_count > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
}
info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped); info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped);
Ok(PreloadResult { Ok(PreloadResult {
@@ -1731,9 +1939,10 @@ pub async fn player_get_cache_config(
#[specta::specta] #[specta::specta]
pub async fn player_configure_jellyfin( pub async fn player_configure_jellyfin(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String, server_url: String,
access_token: String, access_token: String,
_user_id: String, user_id: String,
device_id: String, device_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting"); log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
@@ -1744,12 +1953,31 @@ pub async fn player_configure_jellyfin(
device_id, device_id,
}; };
let client = JellyfinClient::new(config)?; // Legacy client (used for remote session control / casting).
let client = JellyfinClient::new(config.clone())?;
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
// actually report through. Without this, Start/Progress/Stopped never reach
// Jellyfin, so playback position never syncs and you can't resume on another
// device. The reporter shares the player controller's Arc, so populating it
// here lights up reporting on both desktop and Android, on every auth path
// that configures the player (login / restore / reauth).
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let reporter_client = JellyfinClient::new(config)?;
let reporter = crate::playback_reporting::PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(reporter_client))),
user_id,
);
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.set_jellyfin_client(Some(client)); controller.set_jellyfin_client(Some(client));
controller.set_playback_reporter(Some(reporter)).await;
log::info!("[PlayerCommand] Jellyfin client configured successfully"); log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
Ok(()) Ok(())
} }
@@ -1763,13 +1991,105 @@ pub async fn player_disable_jellyfin(
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.set_jellyfin_client(None); controller.set_jellyfin_client(None);
controller.set_playback_reporter(None).await;
log::info!("[PlayerCommand] Jellyfin client disabled"); log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
Ok(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// Queue items enqueued as Remote must flip to Local once a completed
/// download exists on disk — this is what makes preloaded tracks (and
/// offline playback after a connection drop) actually use the cache.
#[tokio::test]
async fn test_refresh_queue_local_sources_switches_completed_downloads() {
use super::{refresh_queue_local_sources, DatabaseWrapper};
use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
use crate::storage::Database;
use std::sync::Mutex;
// A real file on disk for the completed download; a missing file for
// the second entry to prove nonexistent files are not switched.
let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("track-a.mp3");
std::fs::write(&existing, b"audio").unwrap();
let missing = dir.join("track-b-missing.mp3");
let _ = std::fs::remove_file(&missing);
let database = Database::open_in_memory().unwrap();
{
let conn = database.connection();
let conn = conn.lock().unwrap();
conn.execute_batch(&format!(
r#"
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-a', 'user1', '{}', 'completed');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-b', 'user1', '{}', 'completed');
"#,
existing.display(),
missing.display()
))
.unwrap();
}
let db = DatabaseWrapper(Mutex::new(database));
let make_item = |id: &str| MediaItem {
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: format!("http://test/Audio/{}/stream", id),
jellyfin_item_id: id.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
let controller = PlayerController::default();
controller
.set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
.unwrap();
let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
assert_eq!(switched, 1, "only the download whose file exists switches");
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
match &queue_lock.items()[0].source {
MediaSource::Local { file_path, jellyfin_item_id } => {
assert_eq!(file_path, &existing);
assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
}
other => panic!("track-a should be local, got {:?}", other),
}
assert!(
matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
"track-b's file is missing, it must stay remote"
);
}
/// Test track index finding in album /// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13 /// This reproduces the bug where clicking songs 1-5 always played song 13
#[test] #[test]
+6
View File
@@ -347,10 +347,16 @@ pub async fn player_add_tracks_by_ids(
#[specta::specta] #[specta::specta]
pub async fn player_skip_to( pub async fn player_skip_to(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
index: usize, index: usize,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
}
// Skip to the index and get the item to play // Skip to the index and get the item to play
let item = { let item = {
let queue = controller.queue(); let queue = controller.queue();
+90
View File
@@ -6,6 +6,7 @@
use tauri::State; use tauri::State;
use super::PlayerStateWrapper; use super::PlayerStateWrapper;
use crate::jellyfin::client::LmsSyncGroup;
/// Play items on a remote Jellyfin session (casting) /// Play items on a remote Jellyfin session (casting)
#[tauri::command] #[tauri::command]
@@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
Err("Jellyfin client not configured".to_string()) Err("Jellyfin client not configured".to_string())
} }
} }
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
//
// The frontend addresses LMS players by MAC address, which it derives from a
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
// plugin REST API via the configured JellyfinClient.
/// List current LMS sync groups.
#[tauri::command]
#[specta::specta]
pub async fn lms_get_sync_groups(
player: State<'_, PlayerStateWrapper>,
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_get_sync_groups().await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
/// `slave_macs` zones join it in sync.
#[tauri::command]
#[specta::specta]
pub async fn lms_create_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_create_sync_group(&master_mac, slave_macs).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Remove a single LMS zone from its sync group (decouple one player).
#[tauri::command]
#[specta::specta]
pub async fn lms_unsync_player(
player: State<'_, PlayerStateWrapper>,
mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Decoupling zone {}", mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_unsync_player(&mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
#[tauri::command]
#[specta::specta]
pub async fn lms_dissolve_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_dissolve_sync_group(&master_mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
+14 -2
View File
@@ -142,6 +142,7 @@ pub async fn player_play_next_episode(
pub async fn player_on_playback_ended( pub async fn player_on_playback_ended(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>, repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item_id: Option<String>, item_id: Option<String>,
repository_handle: Option<String>, repository_handle: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -174,8 +175,14 @@ pub async fn player_on_playback_ended(
// Handle the decision // Handle the decision
match decision { match decision {
AutoplayDecision::Stop => { AutoplayDecision::Stop => {
log::debug!("[Autoplay] Decision: Stop playback"); log::info!("[Autoplay] Decision: Stop playback");
let controller = controller_arc.lock().await; let controller = controller_arc.lock().await;
// Clear the queue so the frontend's currentQueueItem becomes null and
// the mini player hides. Without this, the queue still holds the last
// track and the bar would linger (the frontend keeps the bar visible
// through transient idle blips as long as a queue item exists).
controller.clear_queue();
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() { if let Some(emitter) = controller.event_emitter() {
// Emit StateChanged to idle to clear the current media from mini player // Emit StateChanged to idle to clear the current media from mini player
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
@@ -187,9 +194,14 @@ pub async fn player_on_playback_ended(
} }
} }
AutoplayDecision::AdvanceToNext => { AutoplayDecision::AdvanceToNext => {
log::debug!("[Autoplay] Decision: Advance to next track"); log::info!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue // Advance to next track in queue
let controller = controller_arc.lock().await; let controller = controller_arc.lock().await;
// Prefer downloads that completed since the queue was built (e.g.
// preloaded upcoming tracks) over continuing to stream.
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
log::warn!("[Autoplay] Failed to refresh local sources: {}", e);
}
if let Err(e) = controller.next() { if let Err(e) = controller.next() {
log::error!("[Autoplay] Failed to advance to next track: {}", e); log::error!("[Autoplay] Failed to advance to next track: {}", e);
// Emit PlaybackEnded event on error // Emit PlaybackEnded event on error
+69
View File
@@ -52,6 +52,7 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
#[specta::specta] #[specta::specta]
pub async fn repository_create( pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
player: State<'_, crate::commands::player::PlayerStateWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>, db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>, connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String, server_url: String,
@@ -115,6 +116,14 @@ pub async fn repository_create(
manager.0.create(handle.clone(), hybrid); manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully"); info!("[REPO] Repository stored successfully");
// Give the player controller a repository for next-episode lookups. The
// Android playback-ended callback has no repository handle, so without
// this the episode autoplay countdown never triggers there.
if let Some(repo) = manager.0.get(&handle) {
let controller = player.0.lock().await;
controller.set_repository(repo);
}
Ok(handle) Ok(handle)
} }
@@ -179,6 +188,23 @@ pub async fn repository_get_item(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Query the optional JRay plugin for the actors on screen at time `t`
/// (seconds) in an item. Returns an empty list when JRay isn't installed or
/// has no data for the item, so the caller can render nothing without error.
#[tauri::command]
#[specta::specta]
pub async fn repository_jray_actors_at(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
t: f64,
) -> Result<Vec<crate::repository::JRayActor>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_jray_actors(&item_id, t)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get latest items in a library /// Get latest items in a library
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -410,6 +436,49 @@ pub async fn repository_get_audio_stream_url(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get Live TV channels (broadcast / IPTV) for browsing
#[tauri::command]
#[specta::specta]
pub async fn repository_get_live_tv_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_live_tv_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Get the root list of plugin "Channels"
#[tauri::command]
#[specta::specta]
pub async fn repository_get_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Open a live stream for a Live TV channel / live item
#[tauri::command]
#[specta::specta]
pub async fn repository_open_live_stream(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<LiveStreamInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.open_live_stream(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start /// Report playback start
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
+1
View File
@@ -82,6 +82,7 @@ impl ConnectivityReporter {
/// Current reachability as seen by this reporter (shared with the monitor /// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity. /// and the UI). Useful for callers that want to branch on connectivity.
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
pub async fn is_reachable(&self) -> bool { pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable self.status.read().await.is_server_reachable
} }
+98 -9
View File
@@ -284,22 +284,35 @@ impl JellyfinClient {
} }
/// Seek on a remote session /// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek( pub async fn session_seek(
&self, &self,
session_id: String, session_id: String,
position_ticks: i64, position_ticks: i64,
) -> Result<(), String> { ) -> Result<(), String> {
#[derive(serde::Serialize)] let url = format!(
#[serde(rename_all = "PascalCase")] "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
struct SeekRequest { self.config.server_url, session_id, position_ticks
seek_position_ticks: i64, );
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
} }
let request = SeekRequest { log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
seek_position_ticks: position_ticks, Ok(())
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
} }
/// Send a full GeneralCommand to a remote session. /// Send a full GeneralCommand to a remote session.
@@ -371,6 +384,82 @@ impl JellyfinClient {
let sessions = self.get_sessions().await?; let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id))) Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
} }
// --- JellyLMS multi-room sync groups -----------------------------------
//
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
// players ("zones") into synchronized multi-room sync groups. Players are
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
// stripping the `lms-` prefix off the session's device id (see
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
// with deviceId = "lms-{MacAddress}").
/// List current LMS sync groups.
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
self.get("/JellyLms/SyncGroups").await
}
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
pub async fn lms_create_sync_group(
&self,
master_mac: &str,
slave_macs: Vec<String>,
) -> Result<(), String> {
let payload = serde_json::json!({
"MasterMac": master_mac,
"SlaveMacs": slave_macs,
});
self.post("/JellyLms/SyncGroups", &payload).await
}
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
async fn delete(&self, endpoint: &str) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
Ok(())
}
}
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
///
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
/// follow it in lockstep.
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LmsSyncGroup {
#[serde(alias = "MasterMac")]
pub master_mac: String,
#[serde(default, alias = "MasterName")]
pub master_name: String,
#[serde(default, alias = "SlaveMacs")]
pub slave_macs: Vec<String>,
#[serde(default, alias = "SlaveNames")]
pub slave_names: Vec<String>,
} }
/// Default value for supports_remote_control when missing from API /// Default value for supports_remote_control when missing from API
+238 -54
View File
@@ -52,11 +52,13 @@ use commands::{
// Remote session control commands // Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute, remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands // Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper, sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands // Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status, playback_mode_get_remote_status,
// Playback reporting commands // Playback reporting commands
playback_reporter_init, playback_reporter_destroy, playback_reporter_init, playback_reporter_destroy,
@@ -94,11 +96,12 @@ use commands::{
storage_save_series_audio_preference, storage_get_series_audio_preference, storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands // Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_items, repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies, repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums, repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info, repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url, repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_person, repository_get_items_by_person, repository_get_similar_items,
@@ -145,54 +148,126 @@ use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler
/// Handler for media commands from Android MediaSession (lockscreen/notification controls). /// Handler for media commands from Android MediaSession (lockscreen/notification controls).
/// ///
/// Routes commands from the system media controls back to the PlayerController. /// Routes commands from the system media controls to the right place depending on
/// playback mode: in local mode it drives the local `PlayerController`; in remote
/// (cast) mode it forwards transport commands to the remote Jellyfin session so
/// the lockscreen can control whatever is casting. Stop while casting requests a
/// disconnect back to local playback.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
struct MediaSessionHandler { struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>, player: Arc<TokioMutex<PlayerController>>,
playback_mode: Arc<PlaybackModeManager>,
event_emitter: Arc<TauriEventEmitter>,
}
#[cfg(target_os = "android")]
impl MediaSessionHandler {
/// Forward a transport command to the active remote Jellyfin session.
///
/// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
/// threads without a Tokio context.
fn handle_remote_command(&self, command: &str, session_id: String) {
use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
// Stop while casting means "disconnect and resume locally". The frontend
// owns the remote->local transfer (it reloads the item locally), so we
// just signal intent.
if command == "stop" {
self.event_emitter
.emit(PlayerStatusEvent::RemoteDisconnectRequested);
return;
}
let jellyfin_client = {
let player = self.player.blocking_lock();
player.jellyfin_client()
};
let command = command.to_string();
tauri::async_runtime::spawn(async move {
let client = {
let guard = match jellyfin_client.lock() {
Ok(g) => g,
Err(e) => {
error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
return;
}
};
match guard.as_ref() {
Some(c) => c.clone(),
None => {
warn!("[MediaSession] No Jellyfin client for remote command");
return;
}
}
};
// Map lockscreen transport commands onto Jellyfin session commands.
let result = match command.as_str() {
"play" => client.send_session_command(session_id, "Unpause").await,
"pause" => client.send_session_command(session_id, "Pause").await,
"next" => client.send_session_command(session_id, "NextTrack").await,
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(seconds) => {
let ticks = (seconds * 10_000_000.0) as i64;
client.session_seek(session_id, ticks).await
}
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown remote command: {}", command);
Ok(())
}
};
if let Err(e) = result {
error!("[MediaSession] Remote command '{}' failed: {}", command, e);
}
});
}
/// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
let result = match command {
"play" => controller.play(),
"pause" => controller.pause(),
"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(())
}
};
if let Err(e) = result {
error!("[MediaSession] Command '{}' failed: {}", command, e);
}
}
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler { impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) { fn on_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback match self.playback_mode.get_mode() {
let controller = self.player.blocking_lock(); playback_mode::PlaybackMode::Remote { session_id } => {
self.handle_remote_command(command, session_id);
match command {
"play" => {
if let Err(e) = controller.play() {
error!("[MediaSession] Play failed: {}", e);
}
}
"pause" => {
if let Err(e) = controller.pause() {
error!("[MediaSession] Pause failed: {}", e);
}
}
"next" => {
if let Err(e) = controller.next() {
error!("[MediaSession] Next failed: {}", e);
}
}
"previous" => {
if let Err(e) = controller.previous() {
error!("[MediaSession] Previous failed: {}", e);
}
}
"stop" => {
if let Err(e) = controller.stop() {
error!("[MediaSession] Stop failed: {}", e);
}
}
cmd if cmd.starts_with("seek:") => {
if let Ok(pos) = cmd[5..].parse::<f64>() {
if let Err(e) = controller.seek(pos) {
error!("[MediaSession] Seek failed: {}", e);
}
}
}
_ => {
warn!("[MediaSession] Unknown command: {}", command);
} }
_ => self.handle_local_command(command),
} }
} }
} }
@@ -356,6 +431,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
// Throw on error so generated `commands.*` return Promise<T> and throw, // Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention. // matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw) .error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![ .commands(tauri_specta::collect_commands![
// Player commands // Player commands
player_play_item, player_play_item,
@@ -414,6 +492,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
remote_session_seek, remote_session_seek,
remote_session_set_volume, remote_session_set_volume,
remote_session_toggle_mute, remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands // Session polling commands
sessions_set_polling_hint, sessions_set_polling_hint,
sessions_poll_now, sessions_poll_now,
@@ -424,6 +507,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
playback_mode_transfer_to_remote, playback_mode_transfer_to_remote,
playback_mode_get_remote_status, playback_mode_get_remote_status,
playback_mode_transfer_to_local, playback_mode_transfer_to_local,
playback_mode_set_transferring,
// Playback reporting commands // Playback reporting commands
playback_reporter_init, playback_reporter_init,
playback_reporter_destroy, playback_reporter_destroy,
@@ -550,6 +634,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_libraries, repository_get_libraries,
repository_get_items, repository_get_items,
repository_get_item, repository_get_item,
repository_jray_actors_at,
repository_get_latest_items, repository_get_latest_items,
repository_get_resume_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_next_up_episodes,
@@ -561,6 +646,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_playback_info, repository_get_playback_info,
repository_get_video_stream_url, repository_get_video_stream_url,
repository_get_audio_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
repository_report_playback_start, repository_report_playback_start,
repository_report_playback_progress, repository_report_playback_progress,
repository_report_playback_stopped, repository_report_playback_stopped,
@@ -589,6 +677,81 @@ fn specta_builder() -> Builder<tauri::Wry> {
]) ])
} }
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logger // Initialize logger
@@ -596,16 +759,29 @@ pub fn run() {
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.init(); .init();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings` // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist. // startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder(); let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.setup(|app| { .invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory // Initialize database with proper app data directory
// Check for test mode environment variable first // Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -711,16 +887,11 @@ pub fn run() {
let player_arc = Arc::new(TokioMutex::new(player_controller)); let player_arc = Arc::new(TokioMutex::new(player_controller));
// On Android, set up the MediaSession handler for lockscreen controls // On Android, register the player controller for autoplay decisions.
// The MediaSession (lockscreen) handler is set up later, once the
// playback mode manager exists, so it can route to remote sessions.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
});
set_media_command_handler(handler);
// Register player controller for autoplay decisions
player::android::set_player_controller(player_arc.clone()); player::android::set_player_controller(player_arc.clone());
} }
@@ -759,9 +930,19 @@ pub fn run() {
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc); let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
app.manage(session_poller_wrapper); app.manage(session_poller_wrapper);
// On Android, set up remote volume handler for volume button intercept in remote mode // On Android, set up the MediaSession (lockscreen) handler and the
// remote volume handler. Both need the playback mode manager so they
// can route to the active remote session while casting.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let media_handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
playback_mode: playback_mode_arc.clone(),
event_emitter: event_emitter.clone(),
});
set_media_command_handler(media_handler);
info!("[INIT] Setting up remote volume handler for Android..."); info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler { let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(), playback_mode: playback_mode_arc.clone(),
@@ -837,15 +1018,18 @@ pub fn run() {
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager); let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
app.manage(repository_manager_wrapper); app.manage(repository_manager_wrapper);
// Initialize playback reporter wrapper (initially empty, set on login) // Initialize playback reporter wrapper. This MUST share the same Arc
// the player controller and MPV progress loop report through (created
// above at `playback_reporter`), otherwise `playback_reporter_init`
// would populate a dead, parallel Option and no Start/Progress/Stopped
// would ever reach Jellyfin.
info!("[INIT] Initializing playback reporter wrapper..."); info!("[INIT] Initializing playback reporter wrapper...");
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None))); let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
app.manage(playback_reporter_wrapper); app.manage(playback_reporter_wrapper);
info!("[INIT] Application setup completed successfully"); info!("[INIT] Application setup completed successfully");
Ok(()) Ok(())
}) })
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+153 -11
View File
@@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle, Idle,
} }
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions /// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager { pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -59,6 +81,19 @@ impl PlaybackModeManager {
self.is_transferring.load(Ordering::Relaxed) self.is_transferring.load(Ordering::Relaxed)
} }
/// Set the transferring flag directly.
///
/// The remote->local transfer is driven from the frontend in two steps
/// (`player_play_tracks` to start local playback, then
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
/// routing depends on this flag: while it's set, `player_play_tracks` plays
/// locally instead of casting back to the remote session. The frontend must
/// raise the flag *before* that first call and lower it when the sequence is
/// done (or aborts), so it can't be left stuck on.
pub fn set_transferring(&self, transferring: bool) {
self.is_transferring.store(transferring, Ordering::Relaxed);
}
/// Send volume command to remote session /// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown" /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback #[allow(dead_code)] // Called from Android JNI callback
@@ -159,7 +194,11 @@ impl PlaybackModeManager {
} }
/// Transfer playback from local device to remote Jellyfin session /// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> { pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED"); debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id); debug!("[PlaybackMode] session_id: {}", session_id);
log::info!( log::info!(
@@ -173,7 +212,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer // Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await; let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag // Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed); self.is_transferring.store(false, Ordering::Relaxed);
@@ -181,12 +220,24 @@ impl PlaybackModeManager {
result result
} }
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> { async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED"); log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id); debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
// position. Capture it so we can resume there and stop it afterwards.
let previous_remote_session = match self.get_mode() {
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
_ => None,
};
// Get current player state and queue context // Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = { let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock..."); log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock..."); debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await; let player = self.player_controller.lock().await;
@@ -195,7 +246,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0); let original_index = queue.current_index().unwrap_or(0);
let items = queue.items(); let items = queue.items();
@@ -210,7 +260,19 @@ impl PlaybackModeManager {
} }
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0); // Prefer the frontend-supplied position when available. The backend
// position is unreliable as a transfer source: on Linux, *video* plays
// in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() is always 0; only the frontend knows the
// true position. We fall back to the live backend position (correct for
// Linux audio via MPV) when the frontend doesn't pass one.
let position = match position_override {
Some(p) => {
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
p
}
None => player.position(),
};
let context = queue.context().clone(); let context = queue.context().clone();
log::info!( log::info!(
@@ -272,12 +334,48 @@ impl PlaybackModeManager {
} }
}; };
// Calculate position in ticks // Remote -> remote switch: take the current track and position from the
let start_position_ticks = if position_seconds > 0.5 { // session we're leaving, since the local player is idle and reports 0.
Some((position_seconds * 10_000_000.0) as i64) if let Some(ref prev_session_id) = previous_remote_session {
log::info!(
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
prev_session_id
);
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
position_seconds
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
now_id,
idx
);
current_index = idx;
} else { } else {
None log::warn!(
}; "[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
now_id,
current_index
);
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
}
}
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs) // Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context { match &queue_context {
@@ -392,6 +490,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string()); return Err("Remote session did not load track in time".to_string());
} }
// Resume at the right position. We send StartPositionTicks in the play
// command above, but some Jellyfin client/server combinations ignore it
// and start from 0. Now that the track is confirmed loaded, issue an
// explicit seek as well (mirrors how the local resume path works). This
// is the reliable mechanism; StartPositionTicks is best-effort.
if let Some(ticks) = start_position_ticks {
log::info!(
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
ticks
);
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
// Non-fatal: the track is already playing, just not at the
// resume point. Log and continue rather than failing the transfer.
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
}
}
// Remote -> remote switch: stop the session we just left so we don't end
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
}
}
// Stop local playback (queue should remain intact for remote session) // Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared"); log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{ {
@@ -577,6 +702,23 @@ mod tests {
); );
} }
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests { mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType}; use crate::player::{MediaItem, MediaSource, MediaType};
+163 -2
View File
@@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter}; use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType}; use super::media::{MediaItem, MediaType};
use super::state::PlayerState; use super::state::PlayerState;
use crate::playback_reporting::{PlaybackReporter, EventThrottler}; use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks /// Global reference to the JavaVM for JNI callbacks
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new(); static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
@@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
} else { } else {
log::error!("[Android] WARNING: No event emitter for position update!"); log::error!("[Android] WARNING: No event emitter for position update!");
} }
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
}
/// Report throttled playback progress to Jellyfin from the Android position
/// callback. No-op until the reporter/throttler are wired (post-login) or when
/// not actively playing.
fn report_android_progress(position: f64) {
let item_id = match SHARED_STATE.get() {
Some(state) => {
let state = state.lock_safe();
if !state.state.is_playing() {
return;
}
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
Some(id) => id,
None => return,
}
}
None => return,
};
let throttler = match POSITION_THROTTLER.get() {
Some(t) => t,
None => return,
};
if !throttler.should_report(&item_id) {
return;
}
let reporter_arc = match PLAYBACK_REPORTER.get() {
Some(r) => r.clone(),
None => return,
};
let position_ticks = seconds_to_ticks(position);
let item_id_for_task = item_id.clone();
// The reporter is async; the JNI callback is sync. Spawn onto the Tokio
// runtime when present, otherwise a throwaway runtime on a new thread.
let spawn_report = move || async move {
let reporter_guard = reporter_arc.lock().await;
if let Some(reporter) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_for_task.clone(),
position_ticks,
is_paused: false,
};
match reporter.report(operation, true).await {
Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task),
Err(e) => log::warn!("[Android] Failed to report progress: {}", e),
}
}
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(spawn_report());
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(spawn_report());
});
}
throttler.mark_reported(&item_id);
} }
/// Called when player state changes. /// Called when player state changes.
@@ -678,7 +748,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn // Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime // JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await { // Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => { Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback"); log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend // Emit PlaybackEnded event to frontend
@@ -1112,6 +1187,92 @@ pub fn disable_remote_volume() -> Result<(), String> {
Ok(()) Ok(())
} }
use crate::player::LockscreenMetadata;
/// Push now-playing metadata and playback state to the Android lockscreen.
///
/// Calls `JellyTauPlaybackService.updateMediaMetadata(...)`. The service must be
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
/// isn't, this is a no-op rather than an error so it can be called freely on
/// every poll tick.
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet (e.g. nothing has played) - nothing to update.
if service_obj.is_null() {
return Ok(());
}
let title = env
.new_string(&meta.title)
.map_err(|e| format!("Failed to create title string: {}", e))?;
let artist = env
.new_string(&meta.artist)
.map_err(|e| format!("Failed to create artist string: {}", e))?;
// album is nullable on the Kotlin side; pass a real String or JObject::null().
let album_obj = match &meta.album {
Some(a) => env
.new_string(a)
.map_err(|e| format!("Failed to create album string: {}", e))?
.into(),
None => jni::objects::JObject::null(),
};
env.call_method(
&service_obj,
"updateMediaMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZ)V",
&[
JValue::Object(&title.into()),
JValue::Object(&artist.into()),
JValue::Object(&album_obj),
JValue::Long(meta.duration_ms),
JValue::Long(meta.position_ms),
JValue::Bool(meta.is_playing as u8),
],
)
.map_err(|e| format!("Failed to update lockscreen metadata: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms /// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> { pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
+24 -6
View File
@@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode}; use super::{MediaSessionType, SleepTimerMode};
@@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions. /// state machine transitions.
/// ///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent { pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback) /// Playback position updated (emitted periodically during playback)
@@ -70,6 +78,11 @@ pub enum PlayerStatusEvent {
/// Remaining seconds (for time-based timer) /// Remaining seconds (for time-based timer)
remaining_seconds: u32, remaining_seconds: u32,
}, },
/// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
/// webview outside the backend's control — the frontend pauses it on this
/// event.
SleepTimerExpired,
/// Show next episode popup with countdown /// Show next episode popup with countdown
ShowNextEpisodePopup { ShowNextEpisodePopup {
/// Current episode that just finished /// Current episode that just finished
@@ -111,11 +124,14 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin /// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>, sessions: Vec<crate::jellyfin::client::SessionInfo>,
}, },
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
/// frontend owns the two-step remote->local transfer (it must reload the
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
} }
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend. /// Trait for emitting player events to the frontend.
/// ///
/// This abstraction allows backends to emit events without depending /// This abstraction allows backends to emit events without depending
@@ -141,7 +157,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter { impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) { fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) { // Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e); error!("Failed to emit player event: {}", e);
} }
} }
+442 -14
View File
@@ -46,6 +46,37 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs, set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
}; };
/// Metadata for the lockscreen / media notification.
///
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
/// poller fills this in from the remote Jellyfin session and pushes it to the
/// notification so the lockscreen stays in sync while casting.
#[derive(Debug, Clone)]
pub struct LockscreenMetadata {
pub title: String,
pub artist: String,
pub album: Option<String>,
/// Track duration in milliseconds.
pub duration_ms: i64,
/// Current playback position in milliseconds.
pub position_ms: i64,
pub is_playing: bool,
}
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
/// session poller can call it unconditionally and stay platform-agnostic.
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::update_lockscreen_metadata(_meta);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -134,9 +165,18 @@ impl PlayerController {
self.jellyfin_client.clone() self.jellyfin_client.clone()
} }
/// Configure the playback reporter for dual sync (local DB + server) /// Configure the media repository used for next-episode lookups.
/// Will be called from initialization commands after login ///
#[allow(dead_code)] /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
*self.repository.lock_safe() = Some(repo);
}
/// Configure the playback reporter for dual sync (local DB + server).
/// Called from `player_configure_jellyfin` on login/restore/reauth.
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) { pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
let mut reporter_guard = self.playback_reporter.lock().await; let mut reporter_guard = self.playback_reporter.lock().await;
*reporter_guard = reporter; *reporter_guard = reporter;
@@ -311,7 +351,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index /// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> { pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start // Reset autoplay counter on manual queue start
self.reset_autoplay_count(); self.reset_autoplay_count();
@@ -324,11 +384,37 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set) // Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() { if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?; self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
} }
Ok(()) Ok(())
} }
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback /// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> { pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play"); debug!("[PlayerController] play");
@@ -520,6 +606,13 @@ impl PlayerController {
self.queue.clone() self.queue.clone()
} }
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
pub fn clear_queue(&self) {
self.queue.lock_safe().clear();
}
/// Toggle shuffle /// Toggle shuffle
pub fn toggle_shuffle(&self) { pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle(); self.queue.lock_safe().toggle_shuffle();
@@ -624,6 +717,10 @@ impl PlayerController {
mode: SleepTimerMode::Off, mode: SleepTimerMode::Off,
remaining_seconds: 0, remaining_seconds: 0,
}); });
// Tell the frontend playback must stop: HTML5 video
// (Linux) plays outside the backend, so stopping the
// backend below doesn't reach it.
emitter.emit(PlayerStatusEvent::SleepTimerExpired);
} }
drop(timer); drop(timer);
@@ -796,9 +893,17 @@ impl PlayerController {
let repo = self.repository.lock_safe().clone(); let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id); let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo { let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await? // Degrade lookup failures to Stop: playback already ended, and
// surfacing an error here just kills autoplay silently upstream.
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
None
}
}
} else { } else {
debug!("[PlayerController] No repository available for audio-path episode lookup"); warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
None None
}; };
if let Some(next_ep) = next_ep_result { if let Some(next_ep) = next_ep_result {
@@ -853,7 +958,7 @@ impl PlayerController {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason); debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
} }
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id); log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state // Check sleep timer state
let timer_mode = { let timer_mode = {
@@ -886,8 +991,17 @@ impl PlayerController {
_ => {} _ => {}
} }
// Fetch next episode for the video that just ended // Fetch next episode for the video that just ended. Degrade lookup
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? { // failures to Stop: playback already ended, and propagating an error
// here just kills autoplay silently upstream.
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
None
}
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().clone(); let settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count(); let limit_reached = self.increment_autoplay_count();
@@ -937,7 +1051,7 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id { let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(), Some(sid) => sid.clone(),
None => { None => {
debug!("[PlayerController] Current item has no season_id, cannot find next episode"); log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
return Ok(None); return Ok(None);
} }
}; };
@@ -959,19 +1073,19 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead) // (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items; let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX)); episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id); log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
// Find the current episode by ID and return the next one // Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) { if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() { if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1]; let next = &episodes[current_idx + 1];
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1); log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone()))); return Ok(Some((current_repo_item, next.clone())));
} else { } else {
debug!("[PlayerController] Current episode is the last in the season"); log::info!("[PlayerController] Current episode is the last in the season");
} }
} else { } else {
debug!("[PlayerController] Current episode not found in season episodes"); log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
} }
Ok(None) Ok(None)
@@ -1333,6 +1447,44 @@ mod tests {
} }
} }
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test] #[test]
fn test_seek_to_zero() { fn test_seek_to_zero() {
let controller = PlayerController::default(); let controller = PlayerController::default();
@@ -1645,4 +1797,280 @@ mod tests {
let reason = controller.take_end_reason(); let reason = controller.take_end_reason();
assert!(reason.is_none(), "take_end_reason should clear the state"); assert!(reason.is_none(), "take_end_reason should clear the state");
} }
// ===== Next-episode autoplay decision tests =====
use crate::repository::types as repo_types;
/// Mock repository serving a single season of episodes for next-episode
/// lookup tests. Only `get_item` and `get_items` are used by
/// `fetch_next_episode_for_item`; everything else is unreachable.
struct MockEpisodeRepo {
episodes: Vec<repo_types::MediaItem>,
}
impl MockEpisodeRepo {
fn season(count: usize) -> Self {
let episodes = (1..=count)
.map(|i| {
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
item.name = format!("Episode {}", i);
item
})
.collect();
Self { episodes }
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
index_number: Some(index),
series_id: Some("series1".to_string()),
series_name: Some("Test Series".to_string()),
season_id: Some("season1".to_string()),
season_name: Some("Season 1".to_string()),
parent_index_number: Some(1),
user_data: None,
media_streams: None,
media_sources: None,
people: None,
}
}
#[async_trait::async_trait]
impl crate::repository::MediaRepository for MockEpisodeRepo {
async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
unimplemented!()
}
async fn get_items(
&self,
parent_id: &str,
_options: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
assert_eq!(parent_id, "season1", "episode lookup must query the season");
Ok(repo_types::SearchResult {
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
unimplemented!()
}
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
unimplemented!()
}
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
unimplemented!()
}
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
unimplemented!()
}
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
unimplemented!()
}
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
/// Video (HTML5/Linux) path: ending mid-season must produce the
/// next-episode popup with auto-advance.
#[tokio::test]
async fn test_video_playback_ended_offers_next_episode() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
auto_advance,
..
} => {
assert_eq!(current_episode.id, "ep2");
assert_eq!(next_episode.id, "ep3");
assert!(auto_advance, "default settings should auto-advance");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep3", repo)
.await
.expect("decision should succeed");
assert!(matches!(decision, AutoplayDecision::Stop));
}
/// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
/// so the controller-level repository (wired up in `repository_create`)
/// must be used for the next-episode lookup. Regression test for episode
/// autoplay never triggering on Android because no repository was set.
#[tokio::test]
async fn test_playback_ended_uses_controller_repository_for_episodes() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Queue holds the episode that just finished playing
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep2");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
async fn test_playback_ended_without_repository_stops() {
let controller = PlayerController::default();
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(matches!(decision, AutoplayDecision::Stop));
}
} }
+35
View File
@@ -62,6 +62,14 @@ impl QueueManager {
&self.items &self.items
} }
/// Mutable access to queue items.
///
/// Used to re-point streaming entries at a completed local download
/// without disturbing queue order, shuffle state, or the current index.
pub fn items_mut(&mut self) -> &mut [MediaItem] {
&mut self.items
}
/// Get the current item index /// Get the current item index
pub fn current_index(&self) -> Option<usize> { pub fn current_index(&self) -> Option<usize> {
self.current_index self.current_index
@@ -122,6 +130,18 @@ impl QueueManager {
self.context = context; self.context = context;
} }
/// Clear the queue entirely, returning it to the empty state.
///
/// Used when playback genuinely stops (sleep timer fires, or the queue ends
/// with repeat off) so the frontend's `currentQueueItem` becomes null and
/// the mini player hides. History and shuffle order are reset too.
pub fn clear(&mut self) {
self.items.clear();
self.current_index = None;
self.history.clear();
self.shuffle_order.clear();
}
/// Add items to the queue /// Add items to the queue
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) { pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
if items.is_empty() { if items.is_empty() {
@@ -559,6 +579,21 @@ mod tests {
assert_eq!(queue.current().unwrap().id, "item_0"); assert_eq!(queue.current().unwrap().id, "item_0");
} }
/// Test clearing the queue returns it to the empty state so the frontend
/// hides the mini player on a genuine stop.
#[test]
fn test_clear() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1);
assert_eq!(queue.current_index(), Some(1));
queue.clear();
assert_eq!(queue.items().len(), 0);
assert_eq!(queue.current_index(), None);
assert!(queue.current().is_none());
}
/// Test next track navigation /// Test next track navigation
/// ///
/// @req-test: UR-005 - Control media playback (skip to next track) /// @req-test: UR-005 - Control media playback (skip to next track)
+6 -1
View File
@@ -61,7 +61,12 @@ pub enum PlayerState {
} }
impl PlayerState { impl PlayerState {
/// Get the current playback position if available /// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> { pub fn position(&self) -> Option<f64> {
match self { match self {
PlayerState::Playing { position, .. } => Some(*position), PlayerState::Playing { position, .. } => Some(*position),
+47
View File
@@ -45,6 +45,12 @@ impl HybridRepository {
self.online.download_bytes(url).await self.online.download_bytes(url).await
} }
/// Query the JRay plugin for actors on screen at time `t`. Online-only
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<super::JRayActor>, RepoError> {
self.online.get_jray_actors(item_id, t).await
}
/// Get video stream URL with optional seeking support. /// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths. /// This method is online-only since offline playback uses local file paths.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
@@ -415,6 +421,21 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await self.online.get_audio_stream_url(item_id).await
} }
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels require server communication - delegate to online repository
self.online.get_channels().await
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Opening a live stream requires server communication - delegate to online
self.online.open_live_stream(item_id).await
}
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> { async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
// Playback reporting goes directly to server // Playback reporting goes directly to server
self.online.report_playback_start(item_id, position_ticks).await self.online.report_playback_start(item_id, position_ticks).await
@@ -722,6 +743,18 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!() unimplemented!()
} }
@@ -887,6 +920,18 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!() unimplemented!()
} }
@@ -976,6 +1021,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Movie".to_string(), item_type: "Movie".to_string(),
is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()), parent_id: Some("parent-123".to_string()),
library_id: Some("library-456".to_string()), library_id: Some("library-456".to_string()),
@@ -983,6 +1029,7 @@ mod tests {
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]), genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
runtime_ticks: Some(7200000000), runtime_ticks: Some(7200000000),
production_year: Some(2024), production_year: Some(2024),
premiere_date: None,
community_rating: Some(8.5), community_rating: Some(8.5),
official_rating: Some("PG-13".to_string()), official_rating: Some("PG-13".to_string()),
primary_image_tag: Some("image-tag-123".to_string()), primary_image_tag: Some("image-tag-123".to_string()),
+14 -1
View File
@@ -4,7 +4,7 @@ pub mod offline;
pub mod hybrid; pub mod hybrid;
pub use types::*; pub use types::*;
pub use online::OnlineRepository; pub use online::{OnlineRepository, JRayActor};
pub use offline::OfflineRepository; pub use offline::OfflineRepository;
pub use hybrid::HybridRepository; pub use hybrid::HybridRepository;
@@ -117,6 +117,19 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL /// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>; async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
/// Get the root list of plugin "Channels" (Jellyfin Channels feature).
/// Drill-down into a channel reuses `get_items(channel_id, ...)`.
async fn get_channels(&self) -> Result<SearchResult, RepoError>;
/// Open a live stream (Live TV channel or live channel item) for playback.
///
/// Returns the server transcoding URL plus identifiers needed to manage the
/// stream. Required before a live channel can be played over HLS.
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
/// Report playback start /// Report playback start
/// ///
/// @req: UR-025 - Sync watch history and progress back to Jellyfin /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+90 -32
View File
@@ -29,6 +29,7 @@ impl OfflineRepository {
id: item.id.clone(), id: item.id.clone(),
name: item.name, name: item.name,
item_type: item.item_type, item_type: item.item_type,
is_folder: item.is_folder,
server_id: item.server_id, server_id: item.server_id,
parent_id: item.parent_id, parent_id: item.parent_id,
library_id: item.library_id, library_id: item.library_id,
@@ -38,6 +39,7 @@ impl OfflineRepository {
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()), .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
runtime_ticks: item.runtime_ticks, runtime_ticks: item.runtime_ticks,
production_year: item.production_year, production_year: item.production_year,
premiere_date: item.premiere_date,
community_rating: item.community_rating, community_rating: item.community_rating,
official_rating: item.official_rating, official_rating: item.official_rating,
primary_image_tag: item.primary_image_tag, primary_image_tag: item.primary_image_tag,
@@ -92,6 +94,7 @@ struct CachedItem {
id: String, id: String,
name: String, name: String,
item_type: String, item_type: String,
is_folder: bool,
server_id: String, server_id: String,
parent_id: Option<String>, parent_id: Option<String>,
library_id: Option<String>, library_id: Option<String>,
@@ -99,6 +102,7 @@ struct CachedItem {
genres: Option<String>, genres: Option<String>,
runtime_ticks: Option<i64>, runtime_ticks: Option<i64>,
production_year: Option<i32>, production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>, community_rating: Option<f64>,
official_rating: Option<String>, official_rating: Option<String>,
primary_image_tag: Option<String>, primary_image_tag: Option<String>,
@@ -143,6 +147,9 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
season_id: row.get(20)?, season_id: row.get(20)?,
season_name: row.get(21)?, season_name: row.get(21)?,
parent_index_number: row.get(22)?, parent_index_number: row.get(22)?,
// Appended as the final columns in every SELECT that maps through this fn.
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
premiere_date: row.get(24)?,
}) })
} }
@@ -230,24 +237,24 @@ impl OfflineRepository {
let query = Query::with_params( let query = Query::with_params(
"INSERT OR REPLACE INTO items ( "INSERT OR REPLACE INTO items (
id, server_id, library_id, parent_id, id, server_id, library_id, parent_id,
name, item_type, overview, name, item_type, is_folder, overview,
genres, series_id, series_name, genres, series_id, series_name,
season_id, season_name, index_number, parent_index_number, season_id, season_name, index_number, parent_index_number,
album_id, album_name, album_artist, artists, album_id, album_name, album_artist, artists,
production_year, runtime_ticks, production_year, premiere_date, runtime_ticks,
primary_image_tag, backdrop_image_tags, primary_image_tag, backdrop_image_tags,
community_rating, official_rating, community_rating, official_rating,
synced_at synced_at
) VALUES ( ) VALUES (
?1, ?2, ?3, ?4, ?1, ?2, ?3, ?4,
?5, ?6, ?7, ?5, ?6, ?7, ?8,
?8, ?9, ?10, ?9, ?10, ?11,
?11, ?12, ?13, ?14, ?12, ?13, ?14, ?15,
?15, ?16, ?17, ?18, ?16, ?17, ?18, ?19,
?19, ?20, ?20, ?21, ?22,
?21, ?22,
?23, ?24, ?23, ?24,
?25 ?25, ?26,
?27
)", )",
vec![ vec![
QueryParam::String(item.id.clone()), QueryParam::String(item.id.clone()),
@@ -261,6 +268,7 @@ impl OfflineRepository {
}, },
QueryParam::String(item.name.clone()), QueryParam::String(item.name.clone()),
QueryParam::String(item.item_type.clone()), QueryParam::String(item.item_type.clone()),
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
match &item.overview { match &item.overview {
Some(o) => QueryParam::String(o.clone()), Some(o) => QueryParam::String(o.clone()),
None => QueryParam::Null, None => QueryParam::Null,
@@ -313,6 +321,10 @@ impl OfflineRepository {
Some(y) => QueryParam::Int(y), Some(y) => QueryParam::Int(y),
None => QueryParam::Null, None => QueryParam::Null,
}, },
match &item.premiere_date {
Some(d) => QueryParam::String(d.clone()),
None => QueryParam::Null,
},
match item.runtime_ticks { match item.runtime_ticks {
Some(r) => QueryParam::Int64(r), Some(r) => QueryParam::Int64(r),
None => QueryParam::Null, None => QueryParam::Null,
@@ -441,6 +453,13 @@ impl MediaRepository for OfflineRepository {
let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
let start_index = opts.start_index.unwrap_or(0); let start_index = opts.start_index.unwrap_or(0);
// SortBy=Random is the only sort the landing pages rely on offline (the
// hero "surprise" pool); everything else keeps the stable name order.
let order_by = match opts.sort_by.as_deref() {
Some("Random") => "RANDOM()",
_ => "i.sort_name ASC, i.name ASC",
};
// Build type filter for optional filtering // Build type filter for optional filtering
let type_filter = if let Some(include_item_types) = &opts.include_item_types { let type_filter = if let Some(include_item_types) = &opts.include_item_types {
if !include_item_types.is_empty() { if !include_item_types.is_empty() {
@@ -491,13 +510,13 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN available_items ai ON i.id = ai.id INNER JOIN available_items ai ON i.id = ai.id
WHERE i.server_id = ? AND i.parent_id = ?{} WHERE i.server_id = ? AND i.parent_id = ?{}
ORDER BY i.sort_name ASC, i.name ASC ORDER BY {}
LIMIT {} OFFSET {}", LIMIT {} OFFSET {}",
type_filter, limit, start_index type_filter, order_by, limit, start_index
); );
let query = Query::with_params( let query = Query::with_params(
@@ -556,7 +575,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.id = ?", WHERE i.id = ?",
@@ -601,7 +620,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ? WHERE i.server_id = ? AND i.library_id = ?
@@ -639,7 +658,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@@ -664,7 +683,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@@ -752,7 +771,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM ranked_plays rp FROM ranked_plays rp
JOIN items i ON rp.display_id = i.id JOIN items i ON rp.display_id = i.id
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@@ -800,7 +819,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@@ -830,41 +849,57 @@ impl MediaRepository for OfflineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items // Derive genres from cached albums, tallying how many albums carry each
let mut genre_set = std::collections::HashSet::new(); // so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id { let (sql, params) = if let Some(pid) = parent_id {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL", "SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()), QueryParam::String(pid.to_string()),
] ],
) )
} else { } else {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL", "SELECT genres FROM items WHERE server_id = ? \
vec![QueryParam::String(self.server_id.clone())] AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
) )
}; };
let query = Query::with_params(sql, params); let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0)) let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.await .await
.map_err(|e| RepoError::Database { message: e })?; .map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows { for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) { if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec { for genre in genres_vec {
genre_set.insert(genre); if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
} }
} }
} }
let genres = genre_set let genres = counts
.into_iter() .into_iter()
.map(|name| Genre { id: name.clone(), name }) .map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect(); .collect();
Ok(genres) Ok(genres)
@@ -918,7 +953,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@@ -964,6 +999,21 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline) Err(RepoError::Offline)
} }
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels are inherently online-only.
Err(RepoError::Offline)
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live streams cannot be opened offline.
Err(RepoError::Offline)
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
// Cannot report to server while offline // Cannot report to server while offline
Err(RepoError::Offline) Err(RepoError::Offline)
@@ -1055,6 +1105,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0, id: person_data.0,
name: person_data.1, name: person_data.1,
item_type: "Person".to_string(), item_type: "Person".to_string(),
is_folder: false,
server_id: self.server_id.clone(), server_id: self.server_id.clone(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
@@ -1062,6 +1113,7 @@ impl MediaRepository for OfflineRepository {
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: person_data.3, primary_image_tag: person_data.3,
@@ -1115,7 +1167,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN item_people ip ON i.id = ip.item_id JOIN item_people ip ON i.id = ip.item_id
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@@ -1226,7 +1278,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
i.parent_index_number \ i.parent_index_number, i.is_folder, i.premiere_date \
FROM playlist_items pi \ FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \ JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \ WHERE pi.playlist_id = ? \
@@ -1264,6 +1316,8 @@ impl MediaRepository for OfflineRepository {
season_id: row.get(21)?, season_id: row.get(21)?,
season_name: row.get(22)?, season_name: row.get(22)?,
parent_index_number: row.get(23)?, parent_index_number: row.get(23)?,
is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
premiere_date: row.get(25)?,
}; };
Ok((entry_id.to_string(), cached)) Ok((entry_id.to_string(), cached))
}) })
@@ -1430,10 +1484,12 @@ mod tests {
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
item_type TEXT NOT NULL, item_type TEXT NOT NULL,
is_folder INTEGER DEFAULT 0,
overview TEXT, overview TEXT,
genres TEXT, genres TEXT,
runtime_ticks INTEGER, runtime_ticks INTEGER,
production_year INTEGER, production_year INTEGER,
premiere_date TEXT,
community_rating REAL, community_rating REAL,
official_rating TEXT, official_rating TEXT,
primary_image_tag TEXT, primary_image_tag TEXT,
@@ -1502,6 +1558,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()), parent_id: parent_id.map(|s| s.to_string()),
library_id: None, library_id: None,
@@ -1509,6 +1566,7 @@ mod tests {
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: None, primary_image_tag: None,
+214 -8
View File
@@ -11,6 +11,30 @@ use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*}; use super::{MediaRepository, types::*};
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
///
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct JRayActor {
pub name: String,
#[serde(default)]
pub imdb_id: String,
#[serde(default)]
pub tmdb_id: String,
#[serde(default)]
pub jellyfin_id: String,
}
/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
#[derive(Debug, Clone, Deserialize)]
struct JRayContext {
#[serde(default)]
actors: Vec<JRayActor>,
}
/// Online repository - fetches data from Jellyfin server via HTTP /// Online repository - fetches data from Jellyfin server via HTTP
pub struct OnlineRepository { pub struct OnlineRepository {
http_client: Arc<HttpClient>, http_client: Arc<HttpClient>,
@@ -104,6 +128,20 @@ impl OnlineRepository {
.map_err(|e| format!("Failed to read bytes: {}", e)) .map_err(|e| format!("Failed to read bytes: {}", e))
} }
/// Query the JRay plugin for the actors on screen at time `t` (seconds) in
/// the given item. Returns an empty list when the plugin isn't installed or
/// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
/// and "nobody on screen" identically. Other failures propagate.
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<JRayActor>, RepoError> {
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
match self.get_json::<JRayContext>(&endpoint).await {
Ok(context) => Ok(context.actors),
// No plugin / no truth data for this item — not an error to the user.
Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
Err(e) => Err(e),
}
}
/// Make authenticated GET request /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let result = self.get_json_inner(endpoint).await; let result = self.get_json_inner(endpoint).await;
@@ -363,10 +401,13 @@ struct JellyfinItem {
name: String, name: String,
#[serde(rename = "Type")] #[serde(rename = "Type")]
item_type: String, item_type: String,
#[serde(default)]
is_folder: bool,
parent_id: Option<String>, parent_id: Option<String>,
overview: Option<String>, overview: Option<String>,
genres: Option<Vec<String>>, genres: Option<Vec<String>>,
production_year: Option<i32>, production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>, community_rating: Option<f64>,
official_rating: Option<String>, official_rating: Option<String>,
run_time_ticks: Option<i64>, run_time_ticks: Option<i64>,
@@ -450,12 +491,14 @@ impl JellyfinItem {
id: self.id, id: self.id,
name: self.name, name: self.name,
item_type: self.item_type, item_type: self.item_type,
is_folder: self.is_folder,
server_id, server_id,
parent_id: self.parent_id, parent_id: self.parent_id,
library_id: None, // Not provided by Jellyfin API directly library_id: None, // Not provided by Jellyfin API directly
overview: self.overview, overview: self.overview,
genres: self.genres, genres: self.genres,
production_year: self.production_year, production_year: self.production_year,
premiere_date: self.premiere_date,
community_rating: self.community_rating, community_rating: self.community_rating,
official_rating: self.official_rating, official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks, runtime_ticks: self.run_time_ticks,
@@ -580,8 +623,10 @@ impl MediaRepository for OnlineRepository {
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (People only needed in get_item
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -596,7 +641,7 @@ impl MediaRepository for OnlineRepository {
} }
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> { async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources", self.user_id, item_id); let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?; let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.to_media_item(self.user_id.clone()); let media_item = item.to_media_item(self.user_id.clone());
@@ -726,12 +771,14 @@ impl MediaRepository for OnlineRepository {
id: album_id, id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()), name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(), item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(), server_id: first_track.server_id.clone(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@@ -811,7 +858,12 @@ impl MediaRepository for OnlineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id); // Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid)); endpoint.push_str(&format!("&ParentId={}", pid));
@@ -828,17 +880,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre { struct JellyfinGenre {
id: String, id: String,
name: String, name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
} }
let response: GenresResponse = self.get_json(&endpoint).await?; let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response let genres: Vec<Genre> = response
.items .items
.into_iter() .into_iter()
.map(|g| Genre { .map(|g| Genre {
id: g.id, id: g.id,
name: g.name, name: g.name,
album_count: g.album_count.or(g.child_count),
}) })
.collect()) .collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
} }
async fn search( async fn search(
@@ -868,8 +944,9 @@ impl MediaRepository for OnlineRepository {
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (plus Genres so cached items
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult { Ok(SearchResult {
@@ -1107,6 +1184,105 @@ impl MediaRepository for OnlineRepository {
Ok(url) Ok(url)
} }
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
let endpoint = format!(
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
self.user_id
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect())
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Root list of plugin "Channels". Drill-down into a channel folder reuses
// get_items(channel_id, ...).
let endpoint = format!("/Channels?UserId={}", self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let total = response.total_record_count;
let items = response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect();
Ok(SearchResult {
items,
total_record_count: total,
})
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
// server opens the live stream and returns a ready-to-play transcoding URL.
// We send a minimal request; the server applies its own defaults for live.
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamRequest {
user_id: String,
#[serde(rename = "AutoOpenLiveStream")]
auto_open_live_stream: bool,
is_playback: bool,
max_streaming_bitrate: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamResponse {
#[serde(default)]
media_sources: Vec<LiveMediaSource>,
play_session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct LiveMediaSource {
id: String,
transcoding_url: Option<String>,
live_stream_id: Option<String>,
}
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
let request = OpenLiveStreamRequest {
user_id: self.user_id.clone(),
auto_open_live_stream: true,
is_playback: true,
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
let stream_url = match source.transcoding_url {
Some(url) => format!("{}{}", self.server_url, url),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
),
};
Ok(LiveStreamInfo {
stream_url,
play_session_id: response.play_session_id,
live_stream_id: source.live_stream_id,
media_source_id: Some(source.id),
})
}
async fn report_playback_start( async fn report_playback_start(
&self, &self,
item_id: &str, item_id: &str,
@@ -1823,4 +1999,34 @@ mod tests {
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars"); assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry"); assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
} }
#[test]
fn test_jray_context_deserializes_actors() {
// The jray?t= envelope as documented in the JRay truth file format.
let json = r#"{
"actors": [
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
]
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Tom Hanks");
assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
}
#[test]
fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
// Future fields (locations/trivia) must be ignored, and absent id keys
// must default to "" rather than failing to parse.
let json = r#"{
"actors": [ { "name": "Extra" } ],
"locations": ["Beach"],
"trivia": "filmed in 1994"
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Extra");
assert_eq!(ctx.actors[0].imdb_id, "");
assert_eq!(ctx.actors[0].jellyfin_id, "");
}
} }
+32
View File
@@ -104,6 +104,10 @@ pub struct MediaItem {
pub name: String, pub name: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub item_type: String, pub item_type: String,
/// Whether this item is a folder/container (vs a playable leaf). Used to
/// decide whether a channel item drills into a list or plays directly.
#[serde(default)]
pub is_folder: bool,
pub server_id: String, pub server_id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>, pub parent_id: Option<String>,
@@ -115,6 +119,10 @@ pub struct MediaItem {
pub genres: Option<Vec<String>>, pub genres: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub production_year: Option<i32>, pub production_year: Option<i32>,
/// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
/// podcast episodes by release date.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub premiere_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub community_rating: Option<f64>, pub community_rating: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -249,12 +257,30 @@ pub struct PlaybackInfo {
pub needs_transcoding: bool, pub needs_transcoding: bool,
} }
/// Live stream information returned from opening a Live TV / channel stream.
///
/// Unlike on-demand video, a live channel must be "opened" before it can be
/// streamed; the server returns a transcoding URL (already absolute) plus a
/// `live_stream_id` that can later be used to close the stream.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveStreamInfo {
pub stream_url: String,
pub play_session_id: Option<String>,
pub live_stream_id: Option<String>,
pub media_source_id: Option<String>,
}
/// Genre /// Genre
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Genre { pub struct Genre {
pub id: String, pub id: String,
pub name: String, pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
} }
/// Image type /// Image type
@@ -477,12 +503,14 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@@ -616,12 +644,14 @@ mod tests {
id: "track1".to_string(), id: "track1".to_string(),
name: "Test Track".to_string(), name: "Test Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@@ -675,12 +705,14 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Track".to_string(), name: "Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "s1".to_string(), server_id: "s1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
+72
View File
@@ -112,6 +112,18 @@ impl SessionPollerManager {
match sessions_result { match sessions_result {
Ok(sessions) => { Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len()); debug!("[SessionPoller] Fetched {} sessions", sessions.len());
// In remote (cast) mode, mirror the remote session's
// now-playing onto the Android lockscreen. The local
// ExoPlayer is idle while casting, so without this the
// lockscreen shows stale local metadata and a frozen
// scrubber. Driving it here (native poll thread) rather
// than from the WebView keeps it live even when the
// screen is locked and JS timers are throttled.
if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
Self::push_remote_lockscreen(&sessions, &session_id);
}
if let Some(em) = emitter.lock_safe().as_ref() { if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions, sessions,
@@ -150,6 +162,66 @@ impl SessionPollerManager {
*self.current_hint.write_safe() = hint; *self.current_hint.write_safe() = hint;
} }
/// Push the remote session's now-playing onto the Android lockscreen.
///
/// Looks up the active remote session by id and forwards its title/artist/
/// album, duration and position to the media notification. Silently does
/// nothing if the session isn't found or has no now-playing item (e.g. the
/// remote stopped) - the next state change will refresh it.
fn push_remote_lockscreen(
sessions: &[crate::jellyfin::client::SessionInfo],
session_id: &str,
) {
// 100ns Jellyfin ticks -> milliseconds.
const TICKS_PER_MS: i64 = 10_000;
let Some(session) = sessions
.iter()
.find(|s| s.id.as_deref() == Some(session_id))
else {
return;
};
let Some(now_playing) = session.now_playing_item.as_ref() else {
return;
};
let title = now_playing.name.clone().unwrap_or_default();
let artist = now_playing
.artists
.as_ref()
.map(|a| a.join(", "))
.filter(|s| !s.is_empty())
.or_else(|| now_playing.album_artist.clone())
.unwrap_or_default();
let album = now_playing.album.clone();
let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
let (position_ms, is_playing) = session
.play_state
.as_ref()
.map(|ps| {
(
ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
!ps.is_paused.unwrap_or(false),
)
})
.unwrap_or((0, false));
let meta = crate::player::LockscreenMetadata {
title,
artist,
album,
duration_ms,
position_ms,
is_playing,
};
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
}
}
/// Calculate polling interval based on mode and hint /// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 { fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint { match hint {
+13
View File
@@ -22,6 +22,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("015_device_id", MIGRATION_015), ("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016), ("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017), ("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
]; ];
/// Initial schema migration /// Initial schema migration
@@ -680,3 +681,15 @@ const MIGRATION_017: &str = r#"
ALTER TABLE downloads ADD COLUMN stream_url TEXT; ALTER TABLE downloads ADD COLUMN stream_url TEXT;
ALTER TABLE downloads ADD COLUMN target_dir TEXT; ALTER TABLE downloads ADD COLUMN target_dir TEXT;
"#; "#;
/// Migration to record whether a cached item is a folder/container vs a playable
/// leaf. Needed so channel items (which can be either) route to the player or to
/// a browse list correctly. Existing cached rows predate the column and have an
/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
/// causing the hybrid repository to re-fetch them from the server on next browse.
const MIGRATION_018: &str = r#"
ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
-- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL;
"#;
+203 -6
View File
@@ -270,6 +270,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null>
async remoteSessionToggleMute(sessionId: string) : Promise<null> { async remoteSessionToggleMute(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId }); return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
}, },
/**
* List current LMS sync groups.
*/
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
return await TAURI_INVOKE("lms_get_sync_groups");
},
/**
* Fuse LMS zones into a sync group. `master_mac` keeps playing and the
* `slave_macs` zones join it in sync.
*/
async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise<null> {
return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs });
},
/**
* Remove a single LMS zone from its sync group (decouple one player).
*/
async lmsUnsyncPlayer(mac: string) : Promise<null> {
return await TAURI_INVOKE("lms_unsync_player", { mac });
},
/**
* Dissolve an entire LMS sync group, identified by its master's MAC.
*/
async lmsDissolveSyncGroup(masterMac: string) : Promise<null> {
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
},
/** /**
* Set polling frequency hint based on UI state * Set polling frequency hint based on UI state
*/ */
@@ -303,8 +328,8 @@ async playbackModeIsTransferring() : Promise<boolean> {
/** /**
* Transfer playback from local device to a remote Jellyfin session * Transfer playback from local device to a remote Jellyfin session
*/ */
async playbackModeTransferToRemote(sessionId: string) : Promise<null> { async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId }); return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
}, },
/** /**
* Get remote session status (for polling position/duration) * Get remote session status (for polling position/duration)
@@ -322,6 +347,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> { async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks }); return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
}, },
/**
* Set the transferring flag on the playback mode manager.
*
* Used by the frontend remote->local flow to mark the whole two-step sequence
* as a transfer, so `player_play_tracks` starts LOCAL playback instead of
* casting back to the remote session it's leaving. Always pair `true` with a
* later `false` (including on error) so the flag can't stick.
*/
async playbackModeSetTransferring(transferring: boolean) : Promise<null> {
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
},
/** /**
* Initialize playback reporter (called after login) * Initialize playback reporter (called after login)
*/ */
@@ -1032,6 +1068,14 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> { async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_item", { handle, itemId }); return await TAURI_INVOKE("repository_get_item", { handle, itemId });
}, },
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Returns an empty list when JRay isn't installed or
* has no data for the item, so the caller can render nothing without error.
*/
async repositoryJrayActorsAt(handle: string, itemId: string, t: number) : Promise<JRayActor[]> {
return await TAURI_INVOKE("repository_jray_actors_at", { handle, itemId, t });
},
/** /**
* Get latest items in a library * Get latest items in a library
*/ */
@@ -1098,6 +1142,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> { async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId }); return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
}, },
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
},
/**
* Get the root list of plugin "Channels"
*/
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_channels", { handle });
},
/**
* Open a live stream for a Live TV channel / live item
*/
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
},
/** /**
* Report playback start * Report playback start
*/ */
@@ -1276,6 +1338,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
/** user-defined events **/ /** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/ /** user-defined constants **/
@@ -1471,7 +1538,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
/** /**
* Genre * Genre
*/ */
export type Genre = { id: string; name: string } export type Genre = { id: string; name: string;
/**
* Number of albums tagged with this genre, when the backend can supply it
* (online only). Lets the frontend rank/pick genres without probing each
* one. `None` when unknown (e.g. offline).
*/
albumCount: number | null }
/** /**
* Request to get an image URL (with caching) * Request to get an image URL (with caching)
*/ */
@@ -1488,14 +1561,47 @@ export type ImageOptions = { maxWidth?: number | null; maxHeight?: number | null
* Image type * Image type
*/ */
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo" export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
/**
* A single actor returned by the JRay plugin's "context at time t" endpoint.
*
* Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
* `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
* the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
*/
export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jellyfin_id?: string }
/** /**
* Library (media collection) * Library (media collection)
*/ */
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null } export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
* Unlike on-demand video, a live channel must be "opened" before it can be
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
* Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
* follow it in lockstep.
*/
export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] }
/** /**
* Media item * Media item
*/ */
export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } export type MediaItem = { id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/** /**
* Media session type tracking the high-level playback context * Media session type tracking the high-level playback context
*/ */
@@ -1610,7 +1716,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
/** /**
* Request to play tracks by ID (backend fetches metadata) * Request to play tracks by ID (backend fetches metadata)
*/ */
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext } export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext;
/**
* Position (seconds) to resume the starting track from. Used when taking
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/** /**
* Playback information * Playback information
*/ */
@@ -1791,6 +1902,82 @@ mergedIsPlaying: boolean;
* Volume from either local player or remote session (0-1 normalized) * Volume from either local player or remote session (0-1 normalized)
*/ */
mergedVolume: number } mergedVolume: number }
/**
* Events emitted by the player backend to the frontend via Tauri events.
*
* These are distinct from `PlayerEvent` in state.rs, which handles internal
* state machine transitions.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
/**
* Playback position updated (emitted periodically during playback)
*/
{ type: "position_update"; position: number; duration: number } |
/**
* Player state changed
*/
{ type: "state_changed"; state: string; media_id: string | null } |
/**
* Media has finished loading and is ready to play
*/
{ type: "media_loaded"; duration: number } |
/**
* Playback has ended naturally (reached end of media)
*/
{ type: "playback_ended" } |
/**
* Buffering state changed
*/
{ type: "buffering"; percent: number } |
/**
* An error occurred during playback
*/
{ type: "error"; message: string; recoverable: boolean } |
/**
* Volume changed
*/
{ type: "volume_changed"; volume: number; muted: boolean } |
/**
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control the frontend pauses it on this
* event.
*/
{ type: "sleep_timer_expired" } |
/**
* Show next episode popup with countdown
*/
{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } |
/**
* Countdown tick (emitted every second during autoplay countdown)
*/
{ type: "countdown_tick"; remaining_seconds: number } |
/**
* Queue changed (items added, removed, reordered, or playback mode changed)
*/
{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } |
/**
* Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
*/
{ type: "session_changed"; session: MediaSessionType } |
/**
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] } |
/**
* The user asked to disconnect from the remote session and resume locally.
*
* Emitted when the lockscreen Stop button is pressed while casting. The
* frontend owns the two-step remote->local transfer (it must reload the
* media item locally), so the native side only signals intent here.
*/
{ type: "remote_disconnect_requested" }
/** /**
* Result of creating a playlist * Result of creating a playlist
* *
@@ -1807,7 +1994,17 @@ export type PlaylistEntry =
/** /**
* The underlying media item * The underlying media item
*/ */
({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { ({ id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/** /**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId) * The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/ */
+28
View File
@@ -3,6 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend // NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings"; import { commands } from "./bindings";
import type { JRayActor } from "./bindings";
import type { QualityPreset } from "./quality-presets"; import type { QualityPreset } from "./quality-presets";
import type { import type {
Library, Library,
@@ -11,6 +12,7 @@ import type {
GetItemsOptions, GetItemsOptions,
SearchOptions, SearchOptions,
PlaybackInfo, PlaybackInfo,
LiveStreamInfo,
ImageType, ImageType,
ImageOptions, ImageOptions,
Genre, Genre,
@@ -89,6 +91,15 @@ export class RepositoryClient {
return commands.repositoryGetItem(this.ensureHandle(), itemId); return commands.repositoryGetItem(this.ensureHandle(), itemId);
} }
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
* or has no data for the item.
*/
async jrayActorsAt(itemId: string, t: number): Promise<JRayActor[]> {
return commands.repositoryJrayActorsAt(this.ensureHandle(), itemId, t);
}
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> { async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null); return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
} }
@@ -161,6 +172,23 @@ export class RepositoryClient {
); );
} }
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
async getLiveTvChannels(): Promise<MediaItem[]> {
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
}
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
async getChannels(): Promise<SearchResult> {
return commands.repositoryGetChannels(this.ensureHandle());
}
/** Open a live stream for a Live TV channel / live item before HLS playback. */
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
}
// ===== URL Construction Methods (sync, no server call) ===== // ===== URL Construction Methods (sync, no server call) =====
/** /**
+4
View File
@@ -5,12 +5,14 @@
// and adds frontend-only unions/helpers that have no backend equivalent. // and adds frontend-only unions/helpers that have no backend equivalent.
export type { export type {
ArtistItem,
AuthResult, AuthResult,
Genre, Genre,
GetItemsOptions, GetItemsOptions,
ImageOptions, ImageOptions,
ImageType, ImageType,
Library, Library,
LiveStreamInfo,
MediaItem, MediaItem,
MediaSource, MediaSource,
MediaStream, MediaStream,
@@ -50,6 +52,7 @@ export type ItemType =
| "CollectionFolder" | "CollectionFolder"
| "Channel" | "Channel"
| "ChannelFolderItem" | "ChannelFolderItem"
| "TvChannel"
| "Person"; | "Person";
export type LibraryType = export type LibraryType =
@@ -62,6 +65,7 @@ export type LibraryType =
| "boxsets" | "boxsets"
| "playlists" | "playlists"
| "channels" | "channels"
| "livetv"
| "unknown"; | "unknown";
export type PersonType = export type PersonType =
+7 -2
View File
@@ -2,6 +2,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
let { className = "fixed bottom-0 left-0 right-0 z-40" }: { className?: string } = $props();
// Determine if a route is active // Determine if a route is active
function isActive(path: string): boolean { function isActive(path: string): boolean {
@@ -15,7 +20,7 @@
</script> </script>
<!-- Navigation bar visible on all platforms --> <!-- Navigation bar visible on all platforms -->
<nav class="fixed bottom-0 left-0 right-0 bg-[var(--color-surface)] border-t border-gray-800 z-40"> <nav class="{className} bg-[var(--color-surface)] border-t border-gray-800">
<div class="flex items-center justify-around px-4 py-2"> <div class="flex items-center justify-around px-4 py-2">
<!-- Home Button --> <!-- Home Button -->
<button <button
@@ -43,7 +48,7 @@
<!-- Library Button --> <!-- Library Button -->
<button <button
onclick={() => goto('/library')} onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}" class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library" aria-label="Library"
> >
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -152,7 +153,7 @@
class="flex-1 min-w-0 text-left" class="flex-1 min-w-0 text-left"
> >
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.album_name} {truncateMiddle(album.album_name, 40)}
</p> </p>
<p class="text-xs text-gray-400 truncate"> <p class="text-xs text-gray-400 truncate">
{album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"} {album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"}
@@ -26,17 +26,28 @@
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props(); let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches to fill the height of its scroll container (the page's // The strip stretches from where it sits down to just above the bottom nav /
// <main>, which sits below the sticky header) from the sticky top offset down // mini-player bars. Those bars are pinned to the bottom of the screen, so the
// to the bottom gap reserved for the nav / mini-player bars. We measure the // hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// container's visible height live so it stays correct regardless of header // top against that floor (clamped to non-negative) and update on scroll/resize
// size, platform, or window resizes. // so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null); let container = $state<HTMLDivElement | null>(null);
let viewportHeight = $state(0); let stripHeight = $state(0);
function measure() { function measure() {
const scroller = container?.closest("main") ?? document.documentElement; if (!container) return;
viewportHeight = scroller.clientHeight; const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
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
} }
$effect(() => { $effect(() => {
@@ -44,13 +55,21 @@
const scroller = container?.closest("main"); const scroller = container?.closest("main");
const ro = new ResizeObserver(measure); const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller); if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
return () => { return () => {
ro.disconnect(); ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure); window.removeEventListener("resize", measure);
}; };
}); });
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]); const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null); let activeLetter = $state<string | null>(null);
@@ -99,7 +118,7 @@
<div <div
bind:this={container} bind:this={container}
class="flex flex-col items-center justify-between select-none touch-none py-1" class="flex flex-col items-center justify-between select-none touch-none py-1"
style="height: calc({viewportHeight}px - 1.5rem - {bottomGap})" style="height: {stripHeight}px"
onpointerdown={handlePointerDown} onpointerdown={handlePointerDown}
onpointermove={handlePointerMove} onpointermove={handlePointerMove}
role="navigation" role="navigation"
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
@@ -171,7 +172,7 @@
{/if} {/if}
</div> </div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.name} {truncateMiddle(album.name, 40)}
</p> </p>
{#if album.productionYear} {#if album.productionYear}
<p class="text-xs text-gray-400">{album.productionYear}</p> <p class="text-xs text-gray-400">{album.productionYear}</p>
@@ -0,0 +1,54 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { ArtistItem } from "$lib/api/types";
/**
* Renders a comma-separated list of artist names. When the artists carry
* ids (artistItems) each name links to that artist's detail page; otherwise
* it falls back to plain text from the names-only `artists` array.
*
* Consolidates the artist-linking markup used on the player and detail
* screens so every surface behaves the same way.
*/
interface Props {
artistItems?: ArtistItem[] | null;
artists?: string[] | null;
/** Tailwind classes for the link colour/state. */
linkClass?: string;
/** Tailwind classes for the non-linkable fallback text. */
textClass?: string;
/** Called before navigating (e.g. to close a full-screen player). */
onNavigate?: () => void;
}
let {
artistItems = null,
artists = null,
linkClass = "text-[var(--color-jellyfin)] hover:underline",
textClass = "text-gray-400",
onNavigate,
}: Props = $props();
const linkable = $derived(
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onNavigate?.();
goto(`/library/${artistId}`);
}
</script>
{#if linkable.length > 0}
<span class="inline-flex flex-wrap items-baseline gap-1">
{#each linkable as artist, i (artist.id)}
<button onclick={(e) => handleClick(artist.id, e)} class={linkClass}>
{artist.name}
</button>{#if i < linkable.length - 1}<span class={textClass}>,</span>{/if}
{/each}
</span>
{:else if artists && artists.length > 0}
<span class={textClass}>{artists.join(", ")}</span>
{/if}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import ArtistLinks from "./ArtistLinks.svelte";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("ArtistLinks", () => {
beforeEach(() => goto.mockClear());
it("renders a clickable link per artist that navigates to its detail page", async () => {
render(ArtistLinks, {
artistItems: [
{ id: "a1", name: "Radiohead" },
{ id: "a2", name: "Thom Yorke" },
],
});
const link = screen.getByRole("button", { name: "Radiohead" });
await fireEvent.click(link);
expect(goto).toHaveBeenCalledWith("/library/a1");
expect(screen.getByRole("button", { name: "Thom Yorke" })).toBeTruthy();
});
it("falls back to plain text when only names (no ids) are available", () => {
render(ArtistLinks, { artists: ["Unknown Artist"] });
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("Unknown Artist")).toBeTruthy();
});
it("ignores artistItems with blank ids and uses the names fallback", () => {
render(ArtistLinks, {
artistItems: [{ id: " ", name: "No Id Artist" }],
artists: ["No Id Artist"],
});
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("No Id Artist")).toBeTruthy();
});
it("calls onNavigate before navigating (e.g. to close a player)", async () => {
const onNavigate = vi.fn();
render(ArtistLinks, {
artistItems: [{ id: "a1", name: "Radiohead" }],
onNavigate,
});
await fireEvent.click(screen.getByRole("button", { name: "Radiohead" }));
expect(onNavigate).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith("/library/a1");
});
});
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -159,7 +160,7 @@
<!-- Episode title --> <!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg"> <h1 class="text-4xl font-bold text-white drop-shadow-lg">
{episode.name} {truncateMiddle(episode.name, 64)}
</h1> </h1>
<!-- Metadata --> <!-- Metadata -->
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
@@ -134,7 +135,7 @@
{episodeNumber}. {episodeNumber}.
</span> </span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"> <h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{episode.name} {truncateMiddle(episode.name, 56)}
</h3> </h3>
<!-- Played indicator --> <!-- Played indicator -->
{#if episode.userData?.isPlayed} {#if episode.userData?.isPlayed}
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte"; import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -125,7 +127,7 @@
selectedGenre = null; selectedGenre = null;
genreItems = []; genreItems = [];
} else { } else {
goto(config.backPath); navigateBack(config.backPath);
} }
} }
@@ -228,7 +230,7 @@
/> />
</div> </div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 40)}
</p> </p>
{#if item.productionYear} {#if item.productionYear}
<p class="text-sm text-gray-400"> <p class="text-sm text-gray-400">
@@ -2,6 +2,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player"; import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
@@ -130,7 +131,7 @@
} }
function goBack() { function goBack() {
goto(config.backPath); navigateBack(config.backPath);
} }
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`); const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -106,7 +107,7 @@
<!-- Title & Subtitle --> <!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left"> <div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 56)}
</p> </p>
{#if subtitle} {#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p> <p class="text-xs text-gray-400 truncate">{subtitle}</p>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -166,7 +167,7 @@
<div class="mt-2 space-y-0.5"> <div class="mt-2 space-y-0.5">
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 40)}
</p> </p>
{#if subtitle()} {#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p> <p class="text-xs text-gray-400 truncate">{subtitle()}</p>
+3 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { PlayTracksContext } from "$lib/api/bindings"; import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue"; import { queue } from "$lib/stores/queue";
@@ -242,7 +243,7 @@
<span <span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}" class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
> >
{track.name} {truncateMiddle(track.name, 48)}
</span> </span>
</div> </div>
@@ -356,7 +357,7 @@
{#if currentlyPlayingId === track.id} {#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span> <span class="inline-block mr-1"></span>
{/if} {/if}
{track.name} {truncateMiddle(track.name, 48)}
</p> </p>
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1"> <p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
{#if showArtist && showAlbum} {#if showArtist && showAlbum}
@@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(), setQueue: vi.fn(),
addToQueue: vi.fn(), addToQueue: vi.fn(),
}, },
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
})); }));
vi.mock("./DownloadButton.svelte", () => ({ vi.mock("./DownloadButton.svelte", () => ({
+2 -1
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 --> <!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -242,7 +243,7 @@
<div class="p-6 space-y-6 flex-shrink-0"> <div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist --> <!-- Title & Artist -->
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1> <h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap"> <div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length} {#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i} {#each displayMedia?.artistItems as artist, i}
+2 -1
View File
@@ -15,6 +15,7 @@
*/ */
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -322,7 +323,7 @@
<div <div
class="text-sm font-medium text-white truncate block w-full text-left" class="text-sm font-medium text-white truncate block w-full text-left"
> >
{displayMedia?.name} {truncateMiddle(displayMedia?.name, 40)}
</div> </div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1"> <div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length} {#if displayMedia?.artistItems?.length}
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action"; import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -198,7 +199,7 @@
<!-- Info --> <!-- Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}"> <p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
{item.name} {truncateMiddle(item.name, 48)}
</p> </p>
{#if item.artists?.length} {#if item.artists?.length}
<p class="text-xs text-gray-400 truncate"> <p class="text-xs text-gray-400 truncate">
+171 -22
View File
@@ -1,7 +1,9 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, untrack } from "svelte"; import { onMount, onDestroy, untrack } from "svelte";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { JRayActor } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js"; import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -10,7 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte"; import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte"; import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
import { sleepTimerActive } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
interface Props { interface Props {
media: MediaItem | null; media: MediaItem | null;
@@ -30,9 +32,10 @@
onEnded?: () => void; // Called when video playback ends naturally onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available hasNext?: boolean; // Whether there is a next episode available
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
} }
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props(); let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the // The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay // media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -43,6 +46,18 @@
let videoElement: HTMLVideoElement | null = $state(null); let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false); let isPlaying = $state(false);
let currentTime = $state(0); let currentTime = $state(0);
// Guards against onEnded() firing more than once per loaded stream. For
// transcoded HLS, both the native `ended` event and the "fatal network error
// near end of stream" recovery path can fire for the same playback, which
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
// sleep-timer episode counter twice). Reset when the stream URL changes.
let endedFired = $state(false);
function notifyEnded() {
if (endedFired) return;
endedFired = true;
onEnded?.();
}
let isFullscreen = $state(false); let isFullscreen = $state(false);
let showControls = $state(true); let showControls = $state(true);
let showSleepTimerModal = $state(false); let showSleepTimerModal = $state(false);
@@ -188,10 +203,27 @@
seekOffset = 0; seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes isMediaReady = false; // Reset to loading state when stream URL changes
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
lastAppliedInitialPosition = undefined; // New stream - forget the previously-applied resume point
endedFired = false; // New stream loaded - allow onEnded to fire again
} }
}); });
// Pause playback when the time-based sleep timer expires. The backend stops
// its own (MPV/ExoPlayer) playback itself, but the HTML5 <video> element
// plays in the webview outside the backend's control, so it must be paused
// here or the sleep timer never actually stops video playback on Linux.
let lastSleepExpirySeen = $sleepTimerExpiredSignal;
$effect(() => {
if ($sleepTimerExpiredSignal !== lastSleepExpirySeen) {
lastSleepExpirySeen = $sleepTimerExpiredSignal;
if (useHtml5Element && videoElement && !videoElement.paused) {
console.log("[VideoPlayer] Sleep timer expired - pausing playback");
videoElement.pause();
}
}
});
// Set up HLS.js for HLS streams // Set up HLS.js for HLS streams
$effect(() => { $effect(() => {
if (!useHtml5Element || !videoElement || !currentStreamUrl) { if (!useHtml5Element || !videoElement || !currentStreamUrl) {
@@ -285,9 +317,7 @@
if (isNearEnd) { if (isNearEnd) {
// Near end of stream - treat as natural end, don't restart // Near end of stream - treat as natural end, don't restart
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended'); console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
if (onEnded) { notifyEnded();
onEnded();
}
} else if (hlsFatalRecoveryAttempts <= 3) { } else if (hlsFatalRecoveryAttempts <= 3) {
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')'); console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad(); hls!.startLoad();
@@ -359,19 +389,37 @@
} }
}); });
// Handle initial position changes (for resuming same video from different position) // Handle initial position changes (for resuming the *same* loaded video from a
// different position, e.g. the resume point is re-chosen without a reload).
//
// This must only react to a genuine change of the `initialPosition` prop to a
// value we haven't already applied. The previous version re-fired on the very
// first seek (it also depended on, and wrote, `hasPerformedInitialSeek`), and
// since seeking a transcoded/HLS stream re-buffers and fires `canplay` again,
// the two seek paths ping-ponged forever — the player appeared to pause/resume
// in a loop. We now snapshot the last-applied position and only seek when the
// prop actually moves to a new value, untracking the writes so this effect
// can't re-trigger itself.
let lastAppliedInitialPosition = $state<number | undefined>(undefined);
$effect(() => { $effect(() => {
// Track initialPosition changes - if it changes and video is ready, seek to new position
const pos = initialPosition; const pos = initialPosition;
if (pos && pos > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { if (!pos || pos <= 0 || !isMediaReady) return;
// Position changed after initial seek was done - seek to new position // Only act on a real change to a not-yet-applied position.
if (pos === untrack(() => lastAppliedInitialPosition)) return;
// Skip the very first application; handleCanPlay owns the initial seek.
if (!untrack(() => hasPerformedInitialSeek)) {
lastAppliedInitialPosition = pos;
return;
}
untrack(() => {
console.log("[VideoPlayer] Initial position changed, seeking to:", pos); console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
hasPerformedInitialSeek = false; // Reset flag to allow handleCanPlay to seek lastAppliedInitialPosition = pos;
// Trigger seek directly since canplay won't fire for same video if (videoElement) {
videoElement.currentTime = pos; videoElement.currentTime = pos;
currentTime = pos; currentTime = pos;
} }
}); });
});
// Set up progress reporting interval // Set up progress reporting interval
onMount(async () => { onMount(async () => {
@@ -477,12 +525,15 @@
// Load series audio preference (for TV shows) // Load series audio preference (for TV shows)
await loadSeriesAudioPreference(); await loadSeriesAudioPreference();
// Report progress every 10 seconds while playing // Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely.
if (!isLive) {
progressInterval = setInterval(() => { progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) { if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId); onReportProgress(currentTime, false, reportMediaId);
} }
}, 10000); }, 10000);
}
// Debug logging every second // Debug logging every second
debugLogInterval = setInterval(() => { debugLogInterval = setInterval(() => {
@@ -544,8 +595,8 @@
} }
} }
// Report stop when component is destroyed // Report stop when component is destroyed (skip for live - no resume tracking)
if (onReportStop && currentTime > 0) { if (!isLive && onReportStop && currentTime > 0) {
onReportStop(currentTime, reportMediaId); onReportStop(currentTime, reportMediaId);
} }
}); });
@@ -628,6 +679,7 @@
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
console.log("[VideoPlayer] Seeking to initial position:", initialPosition); console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
hasPerformedInitialSeek = true; hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
// Pause video to prevent autoplay from starting at position 0 // Pause video to prevent autoplay from starting at position 0
const wasPlaying = !videoElement.paused; const wasPlaying = !videoElement.paused;
@@ -735,11 +787,63 @@
}, 5000); }, 5000);
} }
// ===== JRay "who's on screen" pause overlay (optional plugin) =====
// On pause, ask the JRay Jellyfin plugin which actors are on screen at the
// current timestamp and show them as a tappable overlay. If the plugin isn't
// installed (or has no data for this item), the call resolves to [] and the
// overlay simply doesn't render. Tapping an actor with a resolved Jellyfin
// Person id navigates to that person's library page.
let jrayActors = $state<JRayActor[]>([]);
// Monotonic token so a slow in-flight request can't overwrite a newer pause
// (or a resume that cleared the list).
let jrayRequestId = 0;
async function fetchJrayActors() {
const itemId = media?.id;
if (!itemId) return;
const token = ++jrayRequestId;
const t = currentTime;
try {
const actors = await auth.getRepository().jrayActorsAt(itemId, t);
// Discard if a newer pause/resume happened while we were waiting.
if (token === jrayRequestId) {
jrayActors = actors;
}
} catch (err) {
console.warn("[VideoPlayer] JRay lookup failed:", err);
if (token === jrayRequestId) jrayActors = [];
}
}
function clearJrayActors() {
jrayRequestId++; // invalidate any in-flight request
jrayActors = [];
}
function openJrayActor(actor: JRayActor) {
if (actor.jellyfin_id) {
goto(`/library/${actor.jellyfin_id}`);
}
}
// Drive the JRay overlay off the single `isPlaying` flag so it works for both
// the HTML5 <video> (desktop) and the native ExoPlayer path (Android, which
// updates isPlaying via the player://state-changed event). Fetch when we go
// paused, clear when we resume. untrack() keeps this from re-running on every
// currentTime tick — only isPlaying transitions matter.
$effect(() => {
if (isPlaying) {
untrack(clearJrayActors);
} else if (isMediaReady) {
untrack(fetchJrayActors);
}
});
function handlePlay() { function handlePlay() {
isPlaying = true; isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play // Report playback start on first play (skip for live - no resume tracking)
if (!hasReportedStart && onReportStart) { if (!isLive && !hasReportedStart && onReportStart) {
onReportStart(currentTime, reportMediaId); onReportStart(currentTime, reportMediaId);
hasReportedStart = true; hasReportedStart = true;
} }
@@ -757,14 +861,12 @@
function handleEnded() { function handleEnded() {
isPlaying = false; isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends // Report stop when video ends (skip for live - no resume tracking)
if (onReportStop) { if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId); onReportStop(currentTime, reportMediaId);
} }
// Notify parent that video has ended (for next episode popup) // Notify parent that video has ended (for next episode popup)
if (onEnded) { notifyEnded();
onEnded();
}
} }
async function togglePlayPause() { async function togglePlayPause() {
@@ -1391,6 +1493,44 @@
</svg> </svg>
</button> </button>
{/if} {/if}
<!-- JRay "who's on screen" overlay: shown while paused when the JRay plugin
returned actors for the current timestamp. Tapping an actor with a
resolved Jellyfin Person id opens their library page. -->
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
<div class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto">
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
<div class="flex flex-col gap-2">
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
{#if actor.jellyfin_id}
<button
class="flex items-center gap-2 text-left text-white text-sm hover:text-blue-300 transition-colors group"
onclick={() => openJrayActor(actor)}
>
<!-- Headshot from the actor's Jellyfin Person item. Falls back to
a placeholder inside CachedImage when no image exists. -->
<CachedImage
itemId={actor.jellyfin_id}
imageType="Primary"
maxWidth={80}
alt={actor.name}
class="w-8 h-8 rounded-full object-cover flex-shrink-0 ring-1 ring-white/20 group-hover:ring-blue-300/60"
/>
<span>{actor.name}</span>
</button>
{:else}
<span class="flex items-center gap-2 text-white/80 text-sm">
<!-- No resolved Jellyfin id → no headshot available. -->
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
{actor.name.slice(0, 1)}
</span>
<span>{actor.name}</span>
</span>
{/if}
{/each}
</div>
</div>
{/if}
</div> </div>
<!-- Controls --> <!-- Controls -->
@@ -1404,7 +1544,15 @@
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2> <h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
</div> </div>
<!-- Progress bar --> <!-- Progress bar (hidden for live streams - no fixed timeline) -->
{#if isLive}
<div class="flex items-center gap-2 mb-2">
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
LIVE
</span>
</div>
{:else}
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span> <span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
<input <input
@@ -1424,6 +1572,7 @@
/> />
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span> <span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
</div> </div>
{/if}
<!-- Control buttons --> <!-- Control buttons -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -1,7 +1,10 @@
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 --> <!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store";
import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode"; import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
import { playbackPosition } from "$lib/stores/player";
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
interface Props { interface Props {
@@ -12,10 +15,24 @@
let { isOpen = false, onClose, onSelectSession }: Props = $props(); let { isOpen = false, onClose, onSelectSession }: Props = $props();
// MACs currently part of any sync group, for rendering toggle state.
const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap(
(g) => [g.masterMac, ...(g.slaveMacs ?? [])]
)));
// The sync master is the LMS zone we're currently controlling. Zones can only
// be fused once we have a master to fuse them into.
const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null);
function isZoneFused(session: Session): boolean {
const mac = macForSession(session);
return mac !== null && groupedMacs.has(mac);
}
async function handleSessionSelect(session: Session) { async function handleSessionSelect(session: Session) {
try { try {
// Transfer playback to remote session // Transfer playback to remote session, resuming at our current position
await playbackMode.transferToRemote(session.id); await playbackMode.transferToRemote(session.id, get(playbackPosition));
if (onSelectSession) { if (onSelectSession) {
onSelectSession(session); onSelectSession(session);
@@ -29,6 +46,32 @@
} }
} }
// Toggle an LMS zone in/out of the current master's sync group. If there's no
// master yet (we're not controlling an LMS zone), the click falls back to a
// normal transfer so the user can start playing on a zone first.
async function handleLmsZoneToggle(session: Session) {
const zoneMac = macForSession(session);
if (!zoneMac) return;
if (!masterMac) {
// No master yet — start playback on this zone; it becomes the master.
await handleSessionSelect(session);
await lmsSync.refresh();
return;
}
try {
if (isZoneFused(session)) {
await lmsSync.decoupleZone(zoneMac);
} else {
await lmsSync.fuseZone(masterMac, zoneMac);
}
} catch (error) {
console.error("Failed to toggle LMS zone:", error);
// Error is surfaced via the lmsSync store
}
}
async function handleTransferToLocal() { async function handleTransferToLocal() {
try { try {
await playbackMode.transferToLocal(); await playbackMode.transferToLocal();
@@ -78,6 +121,7 @@
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
sessions.refresh(); sessions.refresh();
lmsSync.refresh();
} }
}); });
</script> </script>
@@ -165,14 +209,24 @@
<!-- Available sessions --> <!-- Available sessions -->
{#each $controllableSessions as session (session.id)} {#each $controllableSessions as session (session.id)}
{#if session.id !== $selectedSession?.id} {#if session.id !== $selectedSession?.id}
{@const lmsZone = isLmsSession(session)}
{@const fused = lmsZone && isZoneFused(session)}
<button <button
onclick={() => handleSessionSelect(session)} onclick={() => (lmsZone ? handleLmsZoneToggle(session) : handleSessionSelect(session))}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left" disabled={$lmsSync.isBusy}
aria-pressed={lmsZone ? fused : undefined}
class="w-full p-4 rounded-lg border transition-all text-left disabled:opacity-60
{fused
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
: 'border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5'}"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0"> <div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
{#if getSessionIcon(session.client) === "tv"} {#if lmsZone}
<!-- speaker icon for LMS zones -->
<path d="M12 2a1 1 0 011 1v18a1 1 0 01-1 1H6a2 2 0 01-2-2V4a2 2 0 012-2h6zm0 2H6v16h6V4zm-3 9a2 2 0 110 4 2 2 0 010-4zm0-2a4 4 0 100 8 4 4 0 000-8zm0-4a1 1 0 110 2 1 1 0 010-2zm9 0h2v2h-2V7zm0 4h2v2h-2v-2z" />
{:else if getSessionIcon(session.client) === "tv"}
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" /> <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
{:else if getSessionIcon(session.client) === "web"} {:else if getSessionIcon(session.client) === "web"}
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" /> <path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
@@ -185,14 +239,33 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="font-medium text-white truncate">{session.deviceName}</h3> <h3 class="font-medium text-white truncate">{session.deviceName}</h3>
<p class="text-sm text-gray-400 truncate">{session.client}</p> <p class="text-sm text-gray-400 truncate">
{#if lmsZone}
{fused ? "In sync group — tap to remove" : masterMac ? "Tap to add to group" : "Speaker zone"}
{:else}
{session.client}
{/if}
</p>
{#if session.nowPlayingItem} {#if session.nowPlayingItem}
<p class="text-xs text-gray-500 truncate mt-1"> <p class="text-xs text-gray-500 truncate mt-1">
Playing: {session.nowPlayingItem.name} Playing: {session.nowPlayingItem.name}
</p> </p>
{/if} {/if}
</div> </div>
{#if session.playState} {#if lmsZone}
<!-- Fuse toggle indicator -->
<div class="flex-shrink-0">
{#if fused}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{:else}
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke-width="2" />
</svg>
{/if}
</div>
{:else if session.playState}
<div class="flex-shrink-0"> <div class="flex-shrink-0">
{#if session.playState.isPaused} {#if session.playState.isPaused}
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
@@ -293,6 +366,27 @@
</div> </div>
{/if} {/if}
<!-- LMS sync error -->
{#if $lmsSync.error}
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
<span class="text-sm">{$lmsSync.error}</span>
</div>
<button
onclick={() => lmsSync.clearError()}
class="text-white hover:text-gray-200 transition-colors"
aria-label="Dismiss error"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<!-- Error Display --> <!-- Error Display -->
{#if $transferError} {#if $transferError}
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl"> <div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
@@ -0,0 +1,147 @@
/**
* Player Events Service regression tests
*
* These tests intentionally use the REAL player store (and its real derived
* stores), unlike playerEvents.test.ts which mocks the store away. The bugs
* covered here live in the interaction between the event handler and the
* store's position/duration fields, so a mocked store cannot catch them.
*
* TRACES: UR-005 | DR-001
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get, writable } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import type { PlayerStatusEvent } from "$lib/api/bindings";
// Capture the handler that initPlayerEvents registers so we can drive events
// directly, exactly as the Tauri event bridge would.
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_event: string, handler: any) => {
registeredHandler = handler;
return () => {};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
// The current queue item is what handleStateChanged seeds playing/paused state
// from. Back it with a writable so each test can install its own item.
const currentQueueItemStore = writable<MediaItem | null>(null);
vi.mock("$lib/stores/queue", () => ({
queue: { subscribe: vi.fn() },
currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
}));
// Keep the player in local mode so events aren't skipped as remote.
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
// imports `isRemoteMode` from the same module — provide both.
vi.mock("$lib/stores/playbackMode", () => ({
playbackMode: {
setMode: vi.fn(),
initializeSessionMonitoring: vi.fn(),
subscribe: (run: any) => {
run({ mode: "local", isTransferring: false });
return () => {};
},
},
isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
}));
// Remote-mode merged stores in player.ts read this; stub as no remote session.
vi.mock("$lib/stores/sessions", () => ({
selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
}));
vi.mock("$lib/stores/sleepTimer", () => ({
sleepTimer: { set: vi.fn() },
}));
vi.mock("$lib/stores/nextEpisode", () => ({
nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
}));
vi.mock("$lib/services/preload", () => ({
preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
}));
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "track-1",
name: "Test Track",
type: "Audio",
runTimeTicks: null,
...overrides,
} as MediaItem;
}
async function fire(event: PlayerStatusEvent): Promise<void> {
if (!registeredHandler) throw new Error("handler not registered");
await registeredHandler({ payload: event });
// Let any async work inside the handler settle.
await Promise.resolve();
}
describe("Player Events — pause must not zero the slider duration", () => {
beforeEach(async () => {
// initPlayerEvents is a singleton; reset it so each test re-registers its
// own handler and starts from idle player state.
const { cleanupPlayerEvents } = await import("./playerEvents");
const { player } = await import("$lib/stores/player");
cleanupPlayerEvents();
player.setIdle();
vi.clearAllMocks();
registeredHandler = null;
currentQueueItemStore.set(null);
});
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
// runTimeTicks is null — the previous code recomputed duration as 0 here,
// which collapsed the slider's max and snapped the thumb to the start.
const item = makeItem({ runTimeTicks: null });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");
await initPlayerEvents();
// 1. Track starts playing.
await fire({ type: "state_changed", state: "playing", media_id: item.id });
// 2. Backend reports the real duration once media is loaded / position ticks.
await fire({ type: "position_update", position: 42, duration: 180 });
expect(get(playbackPosition)).toBe(42);
expect(get(playbackDuration)).toBe(180);
// 3. User pauses. Position AND duration must survive — duration is what
// drives the slider's max, so a 0 here is what caused the regression.
await fire({ type: "state_changed", state: "paused", media_id: item.id });
expect(get(playbackPosition)).toBe(42);
expect(get(playbackDuration)).toBe(180);
// Sanity: the store is genuinely paused, not reset to idle/loading.
expect(get(player).state.kind).toBe("paused");
});
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
// 70s in ticks (1 tick = 100ns) → 700_000_000.
const item = makeItem({ runTimeTicks: 700_000_000 });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
const { playbackDuration } = await import("$lib/stores/player");
await initPlayerEvents();
// No position_update yet, so there is no live duration to prefer.
await fire({ type: "state_changed", state: "paused", media_id: item.id });
expect(get(playbackDuration)).toBe(70);
});
});
+57 -64
View File
@@ -8,45 +8,21 @@
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 * TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/ */
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { type UnlistenFn } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings"; import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player"; import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer"; import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode"; import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode";
import { autoPlayNext } from "$lib/services/nextEpisodeService"; import { autoPlayNext } from "$lib/services/nextEpisodeService";
import { preloadUpcomingTracks } from "$lib/services/preload"; import { preloadUpcomingTracks } from "$lib/services/preload";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { get } from "svelte/store"; import { get } from "svelte/store";
/** // PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
* Event types emitted by the player backend. // imported from $lib/api/bindings — they are the authoritative shapes emitted
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs // by src-tauri/src/player/events.rs.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
| { type: "position_update"; position: number; duration: number }
| { type: "state_changed"; state: string; media_id: string | null }
| { type: "media_loaded"; duration: number }
| { type: "playback_ended" }
| { type: "buffering"; percent: number }
| { type: "error"; message: string; recoverable: boolean }
| { type: "volume_changed"; volume: number; muted: boolean }
| { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number }
| { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean }
| { type: "countdown_tick"; remaining_seconds: number };
// Sleep timer mode type
export type SleepTimerMode =
| { kind: "off" }
| { kind: "time"; endTime: number }
| { kind: "endOfTrack" }
| { kind: "episodes"; remaining: number };
/** Event name for player status events from backend */
const PLAYER_EVENT_NAME = "player-event";
let unlistenFn: UnlistenFn | null = null; let unlistenFn: UnlistenFn | null = null;
let isInitialized = false; let isInitialized = false;
@@ -62,12 +38,9 @@ export async function initPlayerEvents(): Promise<void> {
} }
try { try {
unlistenFn = await listen<PlayerStatusEvent>( unlistenFn = await events.playerStatusEvent.listen((event) => {
PLAYER_EVENT_NAME,
(event) => {
handlePlayerEvent(event.payload); handlePlayerEvent(event.payload);
} });
);
isInitialized = true; isInitialized = true;
console.log("Player event listener initialized"); console.log("Player event listener initialized");
} catch (e) { } catch (e) {
@@ -140,6 +113,12 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
handleSleepTimerChanged(event.mode, event.remaining_seconds); handleSleepTimerChanged(event.mode, event.remaining_seconds);
break; break;
case "sleep_timer_expired":
// Backend stops its own playback; this signal lets HTML5 video (which
// plays outside the backend on Linux) pause itself too.
sleepTimerExpiredSignal.update((n) => n + 1);
break;
case "show_next_episode_popup": case "show_next_episode_popup":
handleShowNextEpisodePopup( handleShowNextEpisodePopup(
event.current_episode, event.current_episode,
@@ -152,6 +131,10 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
case "countdown_tick": case "countdown_tick":
handleCountdownTick(event.remaining_seconds); handleCountdownTick(event.remaining_seconds);
break; break;
// queue_changed is handled by the queue store's own listener
// ($lib/stores/queue), which is the single source of truth for
// shuffle/repeat/next/previous. No action needed here.
} }
} }
@@ -165,6 +148,26 @@ function handlePositionUpdate(position: number, duration: number): void {
// Note: Sleep timer logic is now handled entirely in the Rust backend // Note: Sleep timer logic is now handled entirely in the Rust backend
} }
/**
* Resolve the duration to seed playing/paused state with.
*
* For the same track that is already loaded, the live store duration (kept
* fresh by media_loaded / position_update events) is authoritative and is
* preferred falling back to the runTimeTicks estimate only when the store
* has no usable duration yet. This avoids clobbering a known-good duration
* with 0 when runTimeTicks is missing (which would zero the slider's max).
*/
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
if (isSameTrack) {
const live = get(playbackDuration);
if (live > 0) {
return live;
}
}
return estimate;
}
/** /**
* Handle state change events. * Handle state change events.
* *
@@ -186,9 +189,16 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
} }
if (state === "playing" && currentItem) { if (state === "playing" && currentItem) {
// Use 0 for position/duration - will be updated by position_update events // Preserve the current position when the same track is already loaded
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; // (e.g. resuming from pause, or a spurious PlaybackRestart). Only reset
player.setPlaying(currentItem, 0, initialDuration); // to 0 when switching to a different track. position_update events keep
// it fresh either way, but resetting unconditionally caused the time to
// flash to 0:00 on pause/resume.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const startPosition = isSameTrack ? get(playbackPosition) : 0;
const initialDuration = resolveDuration(currentItem, isSameTrack);
player.setPlaying(currentItem, startPosition, initialDuration);
// Trigger preloading of upcoming tracks in the background // Trigger preloading of upcoming tracks in the background
preloadUpcomingTracks().catch((e) => { preloadUpcomingTracks().catch((e) => {
@@ -196,16 +206,20 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
console.debug("[playerEvents] Preload failed (non-critical):", e); console.debug("[playerEvents] Preload failed (non-critical):", e);
}); });
} else if (state === "paused" && currentItem) { } else if (state === "paused" && currentItem) {
// Keep current position from store // Keep current position and duration from store. The same track is
// already loaded on pause, so its live duration (from media_loaded /
// position_update) is authoritative — recomputing from runTimeTicks
// would clobber it with 0 when runTimeTicks is missing, forcing the
// slider's max to 0 and flashing the thumb to the start.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const currentPosition = get(playbackPosition); const currentPosition = get(playbackPosition);
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; const initialDuration = resolveDuration(currentItem, isSameTrack);
player.setPaused(currentItem, currentPosition, initialDuration); player.setPaused(currentItem, currentPosition, initialDuration);
} else if (state === "loading" && currentItem) { } else if (state === "loading" && currentItem) {
player.setLoading(currentItem); player.setLoading(currentItem);
} }
// Update queue status on state change
await updateQueueStatus();
break; break;
case "idle": case "idle":
@@ -218,31 +232,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
playbackMode.setMode("idle"); playbackMode.setMode("idle");
} }
// Update queue status on state change
await updateQueueStatus();
break; break;
} }
} }
/**
* Update queue status from backend.
* Called on state changes instead of polling.
*/
async function updateQueueStatus(): Promise<void> {
try {
const queueStatus = await commands.playerGetQueue();
// Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
hasNext.set(queueStatus.hasNext);
hasPrevious.set(queueStatus.hasPrevious);
shuffle.set(queueStatus.shuffle);
repeat.set(queueStatus.repeat as "off" | "all" | "one");
} catch (e) {
console.error("[playerEvents] Failed to update queue status:", e);
}
}
/** /**
* Handle media loaded event. * Handle media loaded event.
*/ */
+6 -4
View File
@@ -6,11 +6,13 @@ import { writable } from 'svelte/store';
export const isInitialized = writable(false); export const isInitialized = writable(false);
export const pendingSyncCount = writable(0); export const pendingSyncCount = writable(0);
export const isAndroid = writable(false); export const isAndroid = writable(false);
export const shuffle = writable(false); // Shuffle/repeat/next/previous state now lives in the event-driven queue store
export const repeat = writable<'off' | 'all' | 'one'>('off'); // ($lib/stores/queue), the single source of truth.
export const hasNext = writable(false);
export const hasPrevious = writable(false);
export const showSleepTimerModal = writable(false); export const showSleepTimerModal = writable(false);
// Measured height (px) of the fixed bottom UI on Android: BottomNav stacked with
// the global mini player. Published by the root layout via ResizeObserver so the
// library list can reserve exactly that much bottom padding (no magic rem guesses).
export const bottomUiHeight = writable(0);
// Library-specific state // Library-specific state
export const librarySearchQuery = writable(""); export const librarySearchQuery = writable("");
+43
View File
@@ -152,6 +152,47 @@ function createLibraryStore() {
} }
} }
// Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a
// dedicated Jellyfin endpoint rather than the generic /Items browse.
async function loadLiveTvChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const channels = await repo.getLiveTvChannels();
update((s) => ({
...s,
items: channels,
totalItems: channels.length,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return channels;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load Live TV channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
// Load the root list of plugin "Channels" into the items list.
async function loadChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const result = await repo.getChannels();
update((s) => ({
...s,
items: result.items,
totalItems: result.totalRecordCount,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
async function loadItem(itemId: string) { async function loadItem(itemId: string) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -297,6 +338,8 @@ function createLibraryStore() {
subscribe, subscribe,
loadLibraries, loadLibraries,
loadItems, loadItems,
loadLiveTvChannels,
loadChannels,
loadItem, loadItem,
search, search,
setCurrentLibrary, setCurrentLibrary,
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import type { Session } from "$lib/api/types";
const mockInvoke = vi.fn();
vi.mock("$lib/api/bindings", () => ({
commands: {
lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"),
lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) =>
mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }),
lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }),
lmsDissolveSyncGroup: (masterMac: string) =>
mockInvoke("lms_dissolve_sync_group", { masterMac }),
},
}));
import { lmsSync, isLmsSession, macForSession } from "./lmsSync";
function session(deviceId: string | null): Session {
return { id: "s", deviceId } as unknown as Session;
}
describe("LMS session detection", () => {
it("detects LMS zones by the lms- device-id prefix", () => {
expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true);
expect(isLmsSession(session("chromecast-123"))).toBe(false);
expect(isLmsSession(session(null))).toBe(false);
expect(isLmsSession(null)).toBe(false);
});
it("recovers the MAC from an LMS session, null otherwise", () => {
expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff");
expect(macForSession(session("web-xyz"))).toBeNull();
expect(macForSession(null)).toBeNull();
});
});
describe("lmsSync store", () => {
beforeEach(() => {
mockInvoke.mockReset();
lmsSync.reset();
});
it("fusing preserves existing slaves and adds the new zone", async () => {
// Initial group: master M with slave A.
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
// create returns void; refresh after returns the updated group.
mockInvoke.mockResolvedValueOnce(undefined);
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]);
await lmsSync.fuseZone("M", "B");
expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", {
masterMac: "M",
slaveMacs: ["A", "B"],
});
expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]);
});
it("decoupling a slave unsyncs just that player", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // unsync
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
await lmsSync.decoupleZone("A");
expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" });
});
it("decoupling the master dissolves the whole group", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // dissolve
mockInvoke.mockResolvedValueOnce([]);
await lmsSync.decoupleZone("M");
expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" });
});
it("treats a missing plugin (refresh error) as no groups, not fatal", async () => {
mockInvoke.mockRejectedValueOnce(new Error("404"));
await lmsSync.refresh();
expect(get(lmsSync).groups).toEqual([]);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* LMS multi-room sync ("fuse zones") store.
*
* The JellyLMS plugin registers each LMS player as a Jellyfin session whose
* device id is `lms-{MacAddress}`. We use that prefix both to detect which cast
* targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs.
*
* Fusing model (per product decision): the currently-controlled zone is the sync
* master; other selected zones join it. Toggling an already-grouped zone off
* decouples just that zone; toggling the master off dissolves the group.
*
* TRACES: UR-010 | JA-021, JA-025 | DR-037
*/
import { writable, get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import type { Session } from "$lib/api/types";
import type { LmsSyncGroup } from "$lib/api/bindings";
const LMS_DEVICE_PREFIX = "lms-";
/** Is this cast target an LMS zone (and therefore fuseable)? */
export function isLmsSession(session: Session | null | undefined): boolean {
return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX);
}
/** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */
export function macForSession(session: Session | null | undefined): string | null {
const deviceId = session?.deviceId;
if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null;
return deviceId.slice(LMS_DEVICE_PREFIX.length);
}
interface LmsSyncState {
groups: LmsSyncGroup[];
isBusy: boolean;
error: string | null;
}
function createLmsSyncStore() {
const { subscribe, update, set } = writable<LmsSyncState>({
groups: [],
isBusy: false,
error: null,
});
/** Refresh the list of current sync groups from the server. */
async function refresh(): Promise<void> {
try {
const groups = await commands.lmsGetSyncGroups();
update((s) => ({ ...s, groups, error: null }));
} catch (error) {
// The plugin may not be installed; treat as "no groups" rather than fatal.
console.warn("[LmsSync] Failed to load sync groups:", error);
update((s) => ({ ...s, groups: [] }));
}
}
/** All MACs that are part of any sync group (master or slave). */
function groupedMacs(): Set<string> {
const macs = new Set<string>();
for (const g of get({ subscribe }).groups) {
macs.add(g.masterMac);
for (const slave of g.slaveMacs ?? []) macs.add(slave);
}
return macs;
}
/**
* Add an LMS zone to the master's sync group (fuse it in).
* Preserves any zones already grouped with the master.
*/
async function fuseZone(masterMac: string, zoneMac: string): Promise<void> {
if (masterMac === zoneMac) return;
update((s) => ({ ...s, isBusy: true, error: null }));
try {
const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac);
const slaves = new Set(existing?.slaveMacs ?? []);
slaves.add(zoneMac);
await commands.lmsCreateSyncGroup(masterMac, [...slaves]);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fuse zone";
update((s) => ({ ...s, error: message }));
throw error;
} finally {
update((s) => ({ ...s, isBusy: false }));
}
}
/**
* Remove a single zone from its group. If the zone is a group's master, the
* whole group is dissolved (a group can't outlive its master).
*/
async function decoupleZone(zoneMac: string): Promise<void> {
update((s) => ({ ...s, isBusy: true, error: null }));
try {
const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac);
if (asMaster) {
await commands.lmsDissolveSyncGroup(zoneMac);
} else {
await commands.lmsUnsyncPlayer(zoneMac);
}
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to decouple zone";
update((s) => ({ ...s, error: message }));
throw error;
} finally {
update((s) => ({ ...s, isBusy: false }));
}
}
function clearError(): void {
update((s) => ({ ...s, error: null }));
}
function reset(): void {
set({ groups: [], isBusy: false, error: null });
}
return {
subscribe,
refresh,
groupedMacs,
fuseZone,
decoupleZone,
clearError,
reset,
};
}
export const lmsSync = createLmsSyncStore();
+17 -24
View File
@@ -4,6 +4,7 @@
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth"; import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the movies in it. */ /** A single "by genre" row: the genre name plus the movies in it. */
export interface GenreRow { export interface GenreRow {
@@ -42,31 +43,10 @@ function createMoviesStore() {
const { subscribe, set, update } = writable<MoviesState>(initialState); const { subscribe, set, update } = writable<MoviesState>(initialState);
/** /** Artwork check for hero candidates: needs a backdrop or a primary image. */
* Build the hero rotation. Prefer in-progress movies (most personal), then
* fall back to recently added. De-duplicates by id and prefers items that
* carry backdrop/primary artwork for a good banner.
*/
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) => const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag; !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) { async function loadSections(libraryId: string) {
update(s => ({ update(s => ({
...s, ...s,
@@ -77,12 +57,25 @@ function createMoviesStore() {
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
const [resume, latest] = await Promise.all([ const [resume, latest, surprise] = await Promise.all([
repo.getResumeMovies(SECTION_LIMIT), repo.getResumeMovies(SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT), repo.getLatestItems(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["Movie"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]); ]);
const heroItems = buildHero(resume, latest); // Mix the hero: in-progress movies first (most personal), then recent
// additions, then random picks from across the library.
const heroItems = buildHeroMix([resume, latest, surprise], hasArt);
update(s => ({ update(s => ({
...s, ...s,
+120 -28
View File
@@ -2,9 +2,18 @@
// Powers the focused music landing: hero + horizontal sliders. // Powers the focused music landing: hero + horizontal sliders.
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 // TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth"; import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter"; import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the albums in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MusicState { interface MusicState {
// Albums grouped from recently played tracks (backend handles grouping). // Albums grouped from recently played tracks (backend handles grouping).
@@ -15,6 +24,8 @@ interface MusicState {
playlists: MediaItem[]; playlists: MediaItem[];
// Albums the user has played but hasn't returned to in a while. // Albums the user has played but hasn't returned to in a while.
rediscover: MediaItem[]; rediscover: MediaItem[];
// One slider per genre (top genres by album count).
genreRows: GenreRow[];
// Mix of recently played + rediscover, used for the hero banner. // Mix of recently played + rediscover, used for the hero banner.
heroItems: MediaItem[]; heroItems: MediaItem[];
isLoading: boolean; isLoading: boolean;
@@ -22,6 +33,11 @@ interface MusicState {
} }
const SECTION_LIMIT = 16; const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
// Probe a wide pool so the diverse-selection step has genres from across the
// whole (alphabetical) list to choose between, not just the first handful.
const MAX_GENRES_PROBED = 40;
function createMusicStore() { function createMusicStore() {
const initialState: MusicState = { const initialState: MusicState = {
@@ -29,6 +45,7 @@ function createMusicStore() {
newlyAdded: [], newlyAdded: [],
playlists: [], playlists: [],
rediscover: [], rediscover: [],
genreRows: [],
heroItems: [], heroItems: [],
isLoading: false, isLoading: false,
error: null, error: null,
@@ -36,34 +53,10 @@ function createMusicStore() {
const { subscribe, set, update } = writable<MusicState>(initialState); const { subscribe, set, update } = writable<MusicState>(initialState);
/** /** Artwork check for hero candidates: needs a primary image or backdrop. */
* Build the hero rotation from a mix of recently played and rediscover
* albums, interleaved so the banner alternates "fresh in your ears" with
* "remember this?". De-duplicates by id and prefers items that have artwork.
*/
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) => const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0); !!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
const recentPool = recent.filter(hasArt);
const rediscoverPool = rediscover.filter(hasArt);
const result: MediaItem[] = [];
const seen = new Set<string>();
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
for (let i = 0; i < maxLen && result.length < 6; i++) {
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
if (candidate && !seen.has(candidate.id)) {
seen.add(candidate.id);
result.push(candidate);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) { async function loadSections(libraryId: string) {
update(s => ({ update(s => ({
...s, ...s,
@@ -74,7 +67,7 @@ function createMusicStore() {
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([ const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all([
repo.getRecentlyPlayedAudio(SECTION_LIMIT), repo.getRecentlyPlayedAudio(SECTION_LIMIT),
repo.getItems(libraryId, { repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"], includeItemTypes: ["MusicAlbum"],
@@ -91,6 +84,17 @@ function createMusicStore() {
limit: SECTION_LIMIT, limit: SECTION_LIMIT,
}), }),
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]); ]);
// HACK: drop the "Podcasts" folder that lives inside the music library. // HACK: drop the "Podcasts" folder that lives inside the music library.
@@ -98,8 +102,11 @@ function createMusicStore() {
const newlyAddedAlbums = excludePodcasts(newlyAdded.items); const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items); const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover); const rediscoverAlbums = excludePodcasts(rediscover);
const surpriseAlbums = excludePodcasts(surprise);
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums); // Mix the hero: fresh-in-your-ears first, then "remember this?", then
// random albums from across the library.
const heroItems = buildHeroMix([recentlyPlayedAlbums, rediscoverAlbums, surpriseAlbums], hasArt);
update(s => ({ update(s => ({
...s, ...s,
@@ -110,6 +117,10 @@ function createMusicStore() {
heroItems, heroItems,
isLoading: false, isLoading: false,
})); }));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load music sections"; const message = error instanceof Error ? error.message : "Failed to load music sections";
update(s => ({ ...s, isLoading: false, error: message })); update(s => ({ ...s, isLoading: false, error: message }));
@@ -117,6 +128,87 @@ function createMusicStore() {
} }
} }
/** Fetch up to SECTION_LIMIT albums for one genre as a slider row. */
async function loadGenreRow(libraryId: string, genre: Genre): Promise<GenreRow> {
const repo = auth.getRepository();
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
genres: [genre.name],
sortBy: "SortName",
sortOrder: "Ascending",
recursive: true,
limit: SECTION_LIMIT,
});
// HACK: drop the "Podcasts" folder that lives in the music library.
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
}
/**
* Build one slider per genre, showing albums in each. We pick a *diverse* set
* rather than just the most populous otherwise a cluster of near-synonyms
* ("Rock", "Hard Rock", "Classic Rock", ...) crowds out musically distinct
* genres. See selectDiverseGenres. Album genres rarely carry community
* ratings, so we order each row by name.
*
* When the backend reports per-genre album counts (online), we rank and pick
* before fetching, so we only query albums for the genres we'll actually
* show. When counts are missing (offline), we fall back to probing a wide
* pool, dropping empties, then ranking by what came back.
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
// Counts are only *useful* if they actually differentiate genres. Some
// servers return Fields=ItemCounts but populate every genre with the same
// value (or 0), which leaves the list in its original alphabetical order
// after "ranking" — so diversity selection seeds on the first genre and we
// get a wall of A-genres ("avangard", "avan-gard", ...) with no Rock.
// Treat that as "no usable counts" and fall through to the probe path,
// which ranks by genres' real album counts instead.
const positiveCounts = genres
.map(g => g.albumCount)
.filter((c): c is number => c != null && c > 0);
const hasUsefulCounts = new Set(positiveCounts).size > 1;
let genreRows: GenreRow[];
if (hasUsefulCounts) {
// Rank by reported count, pick a diverse subset, then fetch only those.
const ranked = [...genres].sort(
(a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)
);
const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS);
genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter(
row => row.items.length > 0
);
} else {
// No usable counts (offline, a server that ignores Fields=ItemCounts,
// or one that returns uniform/zero counts — see hasUsefulCounts above).
// The genre list is alphabetical, so probing the first N would only
// ever surface A-genres. Sample at an even stride across the whole
// list instead, so the probe pool spans A→Z; then drop empties, rank
// by what came back, and pick a diverse subset.
const probed = sampleAcross(genres, MAX_GENRES_PROBED);
const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g)));
const populated = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length);
genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS);
}
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load music genre rows:", e);
}
}
function reset() { function reset() {
set(initialState); set(initialState);
} }
+94 -3
View File
@@ -9,26 +9,31 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args), invoke: (...args: unknown[]) => mockInvoke(...args),
})); }));
// Mock the sessions store // Mock the sessions store. `selectedSession` is read via svelte's `get()`, which
// calls subscribe and synchronously receives the current value; tests set
// `currentSelectedSession` to control what the store yields.
const mockSelectSession = vi.fn(); const mockSelectSession = vi.fn();
let currentSelectedSession: unknown = null;
vi.mock("./sessions", () => ({ vi.mock("./sessions", () => ({
sessions: { sessions: {
selectSession: (...args: unknown[]) => mockSelectSession(...args), selectSession: (...args: unknown[]) => mockSelectSession(...args),
}, },
selectedSession: { selectedSession: {
subscribe: vi.fn((callback: (value: null) => void) => { subscribe: vi.fn((callback: (value: unknown) => void) => {
callback(null); callback(currentSelectedSession);
return () => {}; return () => {};
}), }),
}, },
})); }));
// Mock auth store // Mock auth store
const mockGetHandle = vi.fn(() => "repo-handle-1");
vi.mock("./auth", () => ({ vi.mock("./auth", () => ({
auth: { auth: {
getRepository: vi.fn(() => ({ getRepository: vi.fn(() => ({
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }), getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"), getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
getHandle: () => mockGetHandle(),
})), })),
}, },
})); }));
@@ -36,6 +41,7 @@ vi.mock("./auth", () => ({
describe("playbackMode store", () => { describe("playbackMode store", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
currentSelectedSession = null;
}); });
afterEach(() => { afterEach(() => {
@@ -161,6 +167,7 @@ describe("playbackMode store", () => {
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", { expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
sessionId: "session-456", sessionId: "session-456",
position: null,
}); });
}); });
@@ -204,6 +211,90 @@ describe("playbackMode store", () => {
}); });
}); });
describe("transferToLocal", () => {
// 50,000,000 ticks = 5 seconds (10M ticks per second).
const REMOTE_POSITION_TICKS = 50_000_000;
const REMOTE_POSITION_SECONDS = 5;
function setRemoteSessionPlaying() {
currentSelectedSession = {
id: "session-123",
nowPlayingItem: { id: "item-abc", name: "Test Track", runTimeTicks: 1_800_000_000 },
playState: { positionTicks: REMOTE_POSITION_TICKS },
};
}
it("resumes local playback at the remote position, not from 0", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
// Bug 2 guard: the resume position must be passed to play_tracks so the
// backend seeks at load time. Restarting from 0 means startPosition is missing.
const playTracksCall = mockInvoke.mock.calls.find((c) => c[0] === "player_play_tracks");
expect(playTracksCall).toBeDefined();
expect(playTracksCall![1]).toMatchObject({
repositoryHandle: "repo-handle-1",
request: {
trackIds: ["item-abc"],
startIndex: 0,
startPosition: REMOTE_POSITION_SECONDS,
},
});
});
it("does not issue a separate player_seek (no start-at-0-then-jump race)", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
const seekCall = mockInvoke.mock.calls.find((c) => c[0] === "player_seek");
expect(seekCall).toBeUndefined();
});
it("tells Rust to stop the remote session with the current item and position", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_local", {
currentItemId: "item-abc",
positionTicks: REMOTE_POSITION_TICKS,
});
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
expect(state.isTransferring).toBe(false);
});
it("switches to local without playing media when remote has nothing playing", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
currentSelectedSession = { id: "session-123", nowPlayingItem: null, playState: null };
await playbackMode.transferToLocal();
expect(mockInvoke).not.toHaveBeenCalledWith("player_play_tracks", expect.anything());
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
});
});
describe("clearError", () => { describe("clearError", () => {
it("should clear transfer error", async () => { it("should clear transfer error", async () => {
const { playbackMode } = await import("./playbackMode"); const { playbackMode } = await import("./playbackMode");
+48 -14
View File
@@ -10,7 +10,7 @@
*/ */
import { writable, get, derived } from "svelte/store"; import { writable, get, derived } from "svelte/store";
import { commands } from "$lib/api/bindings"; import { commands, events } from "$lib/api/bindings";
import { sessions, selectedSession } from "./sessions"; import { sessions, selectedSession } from "./sessions";
import { auth } from "./auth"; import { auth } from "./auth";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
@@ -73,7 +73,10 @@ function createPlaybackModeStore() {
* - Polls remote session until track loads * - Polls remote session until track loads
* - Stops local playback * - Stops local playback
*/ */
async function transferToRemote(sessionId: string | null | undefined): Promise<void> { async function transferToRemote(
sessionId: string | null | undefined,
currentPosition?: number,
): Promise<void> {
console.log("[PlaybackMode] Transferring to remote session:", sessionId); console.log("[PlaybackMode] Transferring to remote session:", sessionId);
update((s) => ({ ...s, isTransferring: true, transferError: null })); update((s) => ({ ...s, isTransferring: true, transferError: null }));
@@ -88,10 +91,17 @@ function createPlaybackModeStore() {
}; };
try { try {
// Pass the caller's current local position so the remote resumes where we
// are. The backend can't reliably read this itself: on Linux video plays in
// the HTML5 <video> element and the MPV backend reports 0. A null override
// falls back to the backend position (correct for Linux audio via MPV).
const positionOverride =
currentPosition !== undefined && currentPosition > 0 ? currentPosition : null;
// Rust handles everything - just wait for it to complete // Rust handles everything - just wait for it to complete
// It includes its own 5-second timeout for track loading // It includes its own 5-second timeout for track loading
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId); console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
await commands.playbackModeTransferToRemote(sessionId ?? ""); await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
console.log("[PlaybackMode] Invoke completed successfully"); console.log("[PlaybackMode] Invoke completed successfully");
if (aborted) { if (aborted) {
@@ -188,14 +198,24 @@ function createPlaybackModeStore() {
// Get repository for handle (backend will fetch playback info via player_play_tracks) // Get repository for handle (backend will fetch playback info via player_play_tracks)
const repository = auth.getRepository(); const repository = auth.getRepository();
// Mark the whole sequence as a transfer in the *Rust* manager too. Without
// this, player_play_tracks sees mode=Remote and casts the track back to the
// remote session instead of playing it locally (the frontend's own
// isTransferring flag is invisible to Rust). Cleared in `finally`.
await commands.playbackModeSetTransferring(true);
// Start local playback (events allowed through because isTransferring=true) // Start local playback (events allowed through because isTransferring=true)
// Use player_play_tracks - backend fetches all metadata from single ID // Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle(); const repositoryHandle = repository.getHandle();
// Pass the resume position so the backend seeks at load time. Doing the
// seek here (rather than a delayed playerSeek) avoids the race where the
// media isn't loaded yet and the seek is lost, restarting from 0.
await commands.playerPlayTracks(repositoryHandle, { await commands.playerPlayTracks(repositoryHandle, {
trackIds: [itemId], trackIds: [itemId],
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
startPosition: positionSeconds,
context: { context: {
type: "search", type: "search",
searchQuery: "", searchQuery: "",
@@ -204,16 +224,6 @@ function createPlaybackModeStore() {
if (aborted) return; if (aborted) return;
// Wait briefly for media to load
await new Promise((resolve) => setTimeout(resolve, 500));
// Seek to position if not at the very start
if (positionSeconds > 0.5) {
await commands.playerSeek(positionSeconds);
}
if (aborted) return;
// Let Rust handle stopping remote playback // Let Rust handle stopping remote playback
await commands.playbackModeTransferToLocal(itemId, positionTicks); await commands.playbackModeTransferToLocal(itemId, positionTicks);
@@ -244,6 +254,14 @@ function createPlaybackModeStore() {
console.error("Transfer to local failed:", error); console.error("Transfer to local failed:", error);
throw error; throw error;
} finally { } finally {
// Always lower the Rust transferring flag so it can't stick on if any step
// above threw (transfer_to_local lowers it on success, but not if we never
// reached it). Safe to call unconditionally.
try {
await commands.playbackModeSetTransferring(false);
} catch (e) {
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
}
currentTransferAbort = null; currentTransferAbort = null;
} }
} }
@@ -257,6 +275,22 @@ function createPlaybackModeStore() {
let consecutiveMisses = 0; let consecutiveMisses = 0;
const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval
// The lockscreen Stop button (while casting) asks the native side to
// disconnect from the remote session and resume locally. The remote->local
// transfer must be driven here because it reloads the media item locally,
// which only the frontend can currently do.
events.playerStatusEvent.listen((event) => {
if (event.payload.type === "remote_disconnect_requested") {
const currentState = get({ subscribe });
if (currentState.mode === "remote") {
console.log("[PlaybackMode] Lockscreen requested disconnect; transferring to local");
transferToLocal().catch((e) =>
console.error("[PlaybackMode] Lockscreen-triggered transfer failed:", e),
);
}
}
});
selectedSession.subscribe((session) => { selectedSession.subscribe((session) => {
const currentState = get({ subscribe }); const currentState = get({ subscribe });
+45 -14
View File
@@ -13,6 +13,7 @@ import type { MediaItem, ItemType } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings"; import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode"; import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions"; import { selectedSession } from "./sessions";
import { currentQueueItem } from "./queue";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
// Merged media item from backend (matches Rust MergedMediaItem) // Merged media item from backend (matches Rust MergedMediaItem)
@@ -249,34 +250,64 @@ export const mergedVolume = derived(
} }
); );
/**
* Whether an item is video content and therefore must NOT appear in the audio
* mini player. Checks the Jellyfin item type (Movie/Episode/live TV) AND the
* backend queue item's `mediaType` discriminator.
*
* The `mediaType` check is essential: a video started via `player_play_item`
* (every Movie/Episode goes through VideoPlayer) is pushed onto the backend
* queue as a PlayerMediaItem with NO `type` field but `mediaType: "video"`.
* Without this, that queue item's absent `type` slips past the Movie/Episode
* check and the video surfaces in the audio mini player after leaving /player.
*/
function isVideoItem(item: MediaItem | null): boolean {
if (!item) return false;
const type = item.type;
if (type === "Movie" || type === "Episode" || type === "TvChannel") {
return true;
}
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
// present even when the Jellyfin `type` is absent (video-only play requests).
const mediaType = (item as { mediaType?: string }).mediaType;
return mediaType === "video";
}
/** /**
* Should show audio miniplayer - state machine gated * Should show audio miniplayer - state machine gated
* Only true when: * Only true when:
* 1. In remote mode with an active session playing media, OR * 1. In remote mode with an active session playing media, OR
* 2. Player is in playing or paused state (not idle, loading, error) * 2. There is an audio item loaded/queued and we are not in a genuine
* AND current media is audio (not video: Movie or Episode) * stopped state. The bar stays visible through transient idle/loading/
* seeking blips so it never flickers while advancing between tracks.
*/ */
export const shouldShowAudioMiniPlayer = derived( export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia, isRemoteMode, selectedSession], [player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
([$player, $media, $isRemote, $session]) => { ([$player, $media, $queueItem, $isRemote, $session]) => {
// In remote mode, show if the remote session has a now-playing item // In remote mode, show if the remote session has a now-playing item
if ($isRemote && $session?.nowPlayingItem) { if ($isRemote && $session?.nowPlayingItem) {
return true; return true;
} }
// Local mode: only show when actively playing or paused // Determine media type from the player state, falling back to the queue
// item (the player store can momentarily lack media during transitions).
const item = $media ?? $queueItem;
if (isVideoItem(item)) {
return false;
}
const state = $player.state; const state = $player.state;
if (state.kind !== "playing" && state.kind !== "paused") {
return false; // A genuine stop clears the queue too, so when the player reports
// idle/error we only hide if there is also no queue item to fall back to.
// This keeps the bar visible through a transient idle/stopped blip emitted
// mid-transition (e.g. sleep-timer churn or a stop-then-load track change),
// while still hiding once playback has truly ended and the queue is empty.
if (state.kind === "idle" || state.kind === "error") {
return $queueItem != null;
} }
// Don't show for video content // playing / paused / loading / seeking — audio is active, show the bar.
const mediaType = $media?.type;
if (mediaType === "Movie" || mediaType === "Episode") {
return false;
}
// Show for audio content
return true; return true;
} }
); );
+103
View File
@@ -0,0 +1,103 @@
/**
* Tests for `shouldShowAudioMiniPlayer` visibility invariant.
*
* The mini player must NOT flicker out while advancing between tracks. The
* backend can momentarily report an idle/stopped state mid-transition, so the
* bar stays visible as long as a queue item still exists, and only hides once
* playback has genuinely ended and the queue has been cleared.
*
* TRACES: UR-005 | DR-009
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { writable } from "svelte/store";
import { get } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
// Mock the dependency stores so we can drive remote mode and the current queue
// item independently of the real backend listeners.
const isRemoteMode = writable(false);
const selectedSession = writable<any>(null);
const currentQueueItem = writable<MediaItem | null>(null);
vi.mock("./playbackMode", () => ({ isRemoteMode }));
vi.mock("./sessions", () => ({ selectedSession }));
vi.mock("./queue", () => ({ currentQueueItem }));
// Imported after the mocks so player.ts picks up the mocked stores.
const { player, shouldShowAudioMiniPlayer } = await import("./player");
const audioItem = { id: "a1", type: "Audio" } as unknown as MediaItem;
const videoItem = { id: "v1", type: "Movie" } as unknown as MediaItem;
describe("shouldShowAudioMiniPlayer", () => {
beforeEach(() => {
isRemoteMode.set(false);
selectedSession.set(null);
currentQueueItem.set(null);
player.setIdle();
});
it("shows while an audio track is playing", () => {
player.setPlaying(audioItem, 0, 100);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("stays visible through a transient idle blip while still queued", () => {
// A track is queued (e.g. mid-transition the backend emits idle but the
// next track is already in the queue).
currentQueueItem.set(audioItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("stays visible through loading/seeking transitions", () => {
currentQueueItem.set(audioItem);
player.setLoading(audioItem);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
player.setSeeking(audioItem, 10);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("hides once playback ends and the queue is cleared", () => {
currentQueueItem.set(null);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for video content even while playing", () => {
player.setPlaying(videoItem, 0, 100);
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for video content reported only via the queue item", () => {
currentQueueItem.set(videoItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides a video queue item that has no Jellyfin `type` but mediaType 'video'", () => {
// Regression: player_play_item (every Movie/Episode via VideoPlayer) pushes
// a backend PlayerMediaItem onto the queue with item_type: None but
// media_type: Video → serialized with no `type` and mediaType: "video".
// Without the mediaType check this slipped past the Movie/Episode guard and
// surfaced the last-played video in the audio mini player after leaving
// /player.
const videoQueueItem = { id: "v2", mediaType: "video" } as unknown as MediaItem;
currentQueueItem.set(videoQueueItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for a live TV channel", () => {
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem;
player.setPlaying(channelItem, 0, 0);
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("shows in remote mode when the session has a now-playing item", () => {
isRemoteMode.set(true);
selectedSession.set({ nowPlayingItem: { id: "r1" } });
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
});
+6 -7
View File
@@ -7,8 +7,7 @@
// TRACES: UR-005, UR-015 | DR-005, DR-020 // TRACES: UR-005, UR-015 | DR-005, DR-020
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event"; import { commands, events } from "$lib/api/bindings";
import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -51,12 +50,12 @@ function createQueueStore() {
// Initial sync from backend // Initial sync from backend
await syncFromRust(); await syncFromRust();
// Listen for queue changed events // Listen for queue changed events (generated tauri-specta event)
unlisten = await listen<QueueChangedEvent>("player-event", (event) => { unlisten = await events.playerStatusEvent.listen((event) => {
if ((event.payload as any).type === "queue_changed") { if (event.payload.type === "queue_changed") {
const queueEvent = event.payload as any; const queueEvent = event.payload;
set({ set({
items: queueEvent.items, items: queueEvent.items as unknown as MediaItem[],
currentIndex: queueEvent.current_index, currentIndex: queueEvent.current_index,
shuffle: queueEvent.shuffle, shuffle: queueEvent.shuffle,
repeat: queueEvent.repeat, repeat: queueEvent.repeat,
+8 -13
View File
@@ -2,8 +2,7 @@
// TRACES: UR-010 | DR-037 // TRACES: UR-010 | DR-037
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event"; import { commands, events } from "$lib/api/bindings";
import { commands } from "$lib/api/bindings";
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
interface SessionsState { interface SessionsState {
@@ -14,11 +13,6 @@ interface SessionsState {
lastUpdated: Date | null; lastUpdated: Date | null;
} }
interface PlayerStatusEvent {
type: string;
sessions?: Session[];
}
function createSessionsStore() { function createSessionsStore() {
const initialState: SessionsState = { const initialState: SessionsState = {
sessions: [], sessions: [],
@@ -30,16 +24,17 @@ function createSessionsStore() {
const { subscribe, update } = writable<SessionsState>(initialState); const { subscribe, update } = writable<SessionsState>(initialState);
// Listen for session updates from Rust backend // Listen for session updates from Rust backend (generated tauri-specta event)
listen<PlayerStatusEvent>("player-event", (event) => { events.playerStatusEvent.listen((event) => {
if (event.payload.type === "sessions_updated" && event.payload.sessions) { if (event.payload.type === "sessions_updated") {
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`); const sessions = event.payload.sessions as unknown as Session[];
event.payload.sessions.forEach((s, i) => { console.log(`[Sessions] Received ${sessions.length} sessions from backend`);
sessions.forEach((s, i) => {
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`); console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
}); });
update((s) => ({ update((s) => ({
...s, ...s,
sessions: event.payload.sessions!, sessions,
lastUpdated: new Date(), lastUpdated: new Date(),
error: null, error: null,
})); }));
+8
View File
@@ -58,6 +58,14 @@ function createSleepTimerStore() {
export const sleepTimer = createSleepTimerStore(); export const sleepTimer = createSleepTimerStore();
/**
* Incremented each time the backend reports the time-based sleep timer
* expired. The backend stops its own (MPV/ExoPlayer) playback itself, but
* HTML5 video on Linux plays in the webview outside the backend's control
* VideoPlayer watches this signal and pauses the <video> element.
*/
export const sleepTimerExpiredSignal = writable(0);
// Derived stores for convenient access // Derived stores for convenient access
export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode); export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode);

Some files were not shown because too many files have changed in this diff Show More