From 8eae4ae253ded154c9e32b5a39de86fbdfd06cc6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 28 Jun 2026 20:14:17 +0200 Subject: [PATCH] layout improvements --- .../dtourolle/jellytau/VideoOverlayManager.kt | 28 +++++ .../jellytau/player/JellyTauPlayer.kt | 68 ++++++++++-- src-tauri/src/commands/player/timers.rs | 6 + src-tauri/src/lib.rs | 82 ++++++++++++++ src-tauri/src/player/mod.rs | 7 ++ src-tauri/src/player/queue.rs | 27 +++++ src/lib/components/BottomNav.svelte | 6 +- src/lib/stores/appState.ts | 4 + src/lib/stores/player.ts | 26 +++-- src/lib/stores/playerVisibility.test.ts | 84 ++++++++++++++ src/routes/+layout.svelte | 105 ++++++++++++------ src/routes/library/+layout.svelte | 19 +++- 12 files changed, 403 insertions(+), 59 deletions(-) create mode 100644 src/lib/stores/playerVisibility.test.ts diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/VideoOverlayManager.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/VideoOverlayManager.kt index d8e9cb3..41a0af4 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/VideoOverlayManager.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/VideoOverlayManager.kt @@ -15,6 +15,8 @@ import com.dtourolle.jellytau.player.JellyTauPlayer object VideoOverlayManager { 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. @@ -51,6 +53,23 @@ object VideoOverlayManager { contentView.addView(surfaceView, 0, layoutParams) 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") } catch (e: Exception) { android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e) @@ -64,6 +83,7 @@ object VideoOverlayManager { */ fun detachVideoSurface(activity: Activity) { try { + removeLayoutListener() attachedSurfaceView?.let { surfaceView -> val contentView = activity.window.decorView.findViewById(android.R.id.content) contentView.removeView(surfaceView) @@ -83,4 +103,12 @@ object VideoOverlayManager { fun isVideoSurfaceAttached(): Boolean { return attachedSurfaceView != null } + + private fun removeLayoutListener() { + contentLayoutListener?.let { listener -> + listenerContentView?.removeOnLayoutChangeListener(listener) + } + contentLayoutListener = null + listenerContentView = null + } } diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index 874100b..0c2acf1 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -161,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) { /** SurfaceView for video playback */ private var surfaceView: SurfaceView? = 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 currentActivity: java.lang.ref.WeakReference? = null @@ -262,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) { } 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() { @@ -873,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) { /** * 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) { + 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 { - surfaceView?.let { view -> - view.layoutParams = view.layoutParams.apply { - this.width = width - this.height = height - } - view.requestLayout() - android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}") + val view = surfaceView ?: return@post + val parent = view.parent as? ViewGroup + // Available area: prefer the parent's measured size, fall back to the screen. + 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() + android.util.Log.d( + "JellyTauPlayer", + "Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})" + ) } } diff --git a/src-tauri/src/commands/player/timers.rs b/src-tauri/src/commands/player/timers.rs index a7c0f93..f859a11 100644 --- a/src-tauri/src/commands/player/timers.rs +++ b/src-tauri/src/commands/player/timers.rs @@ -176,6 +176,12 @@ pub async fn player_on_playback_ended( AutoplayDecision::Stop => { log::debug!("[Autoplay] Decision: Stop playback"); 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() { // Emit StateChanged to idle to clear the current media from mini player // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c470b73..6ac846b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -677,12 +677,94 @@ fn specta_builder() -> Builder { } #[cfg_attr(mobile, tauri::mobile_entry_point)] +/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `