layout improvements
Some checks failed
🏗️ 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

This commit is contained in:
Duncan Tourolle 2026-06-28 20:14:17 +02:00
parent ef7be645b3
commit 8eae4ae253
12 changed files with 403 additions and 59 deletions

View File

@ -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
}
} }

View File

@ -161,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
@ -262,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() {
@ -873,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})"
)
} }
} }

View File

@ -176,6 +176,12 @@ pub async fn player_on_playback_ended(
AutoplayDecision::Stop => { AutoplayDecision::Stop => {
log::debug!("[Autoplay] Decision: Stop playback"); log::debug!("[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

View File

@ -677,12 +677,94 @@ fn specta_builder() -> Builder<tauri::Wry> {
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
/// 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);
}
}
pub fn run() { pub fn run() {
// Initialize logger // Initialize logger
env_logger::Builder::from_default_env() env_logger::Builder::from_default_env()
.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

View File

@ -597,6 +597,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();

View File

@ -122,6 +122,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 +571,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)

View File

@ -3,6 +3,10 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
// 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 {
const pathname = $page.url.pathname; const pathname = $page.url.pathname;
@ -15,7 +19,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

View File

@ -9,6 +9,10 @@ export const isAndroid = writable(false);
// Shuffle/repeat/next/previous state now lives in the event-driven queue store // Shuffle/repeat/next/previous state now lives in the event-driven queue store
// ($lib/stores/queue), the single source of truth. // ($lib/stores/queue), the single source of truth.
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("");

View File

@ -254,8 +254,9 @@ export const mergedVolume = derived(
* 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, currentQueueItem, isRemoteMode, selectedSession], [player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
@ -265,14 +266,6 @@ export const shouldShowAudioMiniPlayer = derived(
return true; return true;
} }
// Local mode: hide only when there is genuinely nothing loaded
// (idle/stopped/error). Keep showing through loading/seeking transitions
// so the mini player doesn't blink out when advancing between tracks.
const state = $player.state;
if (state.kind === "idle" || state.kind === "error") {
return false;
}
// Determine media type from the player state, falling back to the queue // Determine media type from the player state, falling back to the queue
// item (the player store can momentarily lack media during transitions). // item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type; const mediaType = $media?.type ?? $queueItem?.type;
@ -280,7 +273,18 @@ export const shouldShowAudioMiniPlayer = derived(
return false; return false;
} }
// Show for audio content const state = $player.state;
// 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;
}
// playing / paused / loading / seeking — audio is active, show the bar.
return true; return true;
} }
); );

View File

@ -0,0 +1,84 @@
/**
* 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("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);
});
});

View File

@ -17,13 +17,49 @@
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomNav from "$lib/components/BottomNav.svelte"; import BottomNav from "$lib/components/BottomNav.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal, bottomUiHeight } from "$lib/stores/appState";
// Shuffle/repeat/next/previous come from the event-driven queue store, the // Shuffle/repeat/next/previous come from the event-driven queue store, the
// single source of truth (updated instantly on queue_changed). // single source of truth (updated instantly on queue_changed).
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue"; import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
let { children } = $props(); let { children } = $props();
// The fixed bottom UI (mini player stacked over the bottom nav) is measured in
// real time and its height published to `bottomUiHeight`, so pages can reserve
// exactly that much space instead of guessing fixed rem values.
let bottomUiEl = $state<HTMLElement | null>(null);
// Route-level visibility for the fixed bottom UI (the mini player itself also
// self-gates on playback state; when it renders nothing the in-flow slot
// collapses to 0 and the ResizeObserver shrinks the reserved padding).
const pathname = $derived($page.url.pathname);
const showBottomNav = $derived(
$isAuthenticated && !pathname.startsWith('/player/') && !pathname.startsWith('/login')
);
const showGlobalMiniPlayer = $derived(
!pathname.startsWith('/player/') &&
!pathname.startsWith('/login') &&
!pathname.startsWith('/settings') &&
($isAndroid || !pathname.startsWith('/library'))
);
$effect(() => {
const el = bottomUiEl;
if (!el) {
bottomUiHeight.set(0);
return;
}
const ro = new ResizeObserver((entries) => {
bottomUiHeight.set(entries[0]?.contentRect.height ?? el.offsetHeight);
});
ro.observe(el);
bottomUiHeight.set(el.offsetHeight);
return () => {
ro.disconnect();
bottomUiHeight.set(0);
};
});
onMount(async () => { onMount(async () => {
// Detect platform first (synchronously, before any await) so the global // Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render. // mini player's Android visibility gate is correct from the first render.
@ -131,16 +167,13 @@
<!-- Toast notifications (global) --> <!-- Toast notifications (global) -->
<Toast /> <Toast />
<!-- Bottom Navigation (mobile only - show everywhere except player and login) --> <!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal
{#if $isAuthenticated && !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')} flow inside one measured wrapper. The wrapper's height is observed and
<BottomNav /> published to `bottomUiHeight` so pages reserve exactly this much space.
{/if} Mini player is first (visually on top, above the nav). -->
{#if showBottomNav || showGlobalMiniPlayer}
<!-- Mini Player - show everywhere except on full player page, login and settings --> <div bind:this={bottomUiEl} class="fixed bottom-0 left-0 right-0 z-40 flex flex-col">
<!-- Android: Show on all routes (except player/login/settings) --> {#if showGlobalMiniPlayer}
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/settings')}
{#if $isAndroid || !$page.url.pathname.startsWith('/library')}
<MiniPlayer <MiniPlayer
media={$currentMedia} media={$currentMedia}
isPlaying={$isPlaying} isPlaying={$isPlaying}
@ -150,6 +183,7 @@
repeat={$repeat} repeat={$repeat}
hasNext={$hasNext} hasNext={$hasNext}
hasPrevious={$hasPrevious} hasPrevious={$hasPrevious}
className="flex-shrink-0"
onExpand={() => { onExpand={() => {
// Navigate to player page when mini player is expanded // Navigate to player page when mini player is expanded
if ($currentMedia) { if ($currentMedia) {
@ -158,6 +192,12 @@
}} }}
onSleepTimerClick={() => showSleepTimerModal.set(true)} onSleepTimerClick={() => showSleepTimerModal.set(true)}
/> />
{/if}
{#if showBottomNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
<!-- Sleep Timer Modal --> <!-- Sleep Timer Modal -->
<SleepTimerModal <SleepTimerModal
@ -165,7 +205,6 @@
onClose={() => showSleepTimerModal.set(false)} onClose={() => showSleepTimerModal.set(false)}
/> />
{/if} {/if}
{/if}
{:else} {:else}
<div class="flex items-center justify-center h-screen"> <div class="flex items-center justify-center h-screen">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div> <div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>

View File

@ -5,9 +5,9 @@
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player"; import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import { isAndroid } from "$lib/stores/appState"; import { isAndroid, bottomUiHeight } from "$lib/stores/appState";
import { useScrollGuard } from "$lib/composables/useScrollGuard"; import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
@ -204,17 +204,23 @@
</div> </div>
</header> </header>
<!-- Main content (with padding for bottom nav bar and mini player) --> <!-- Main content. The mini player is an in-flow flex sibling below this
scroller (not a fixed overlay), so the list can never render behind it.
Android keeps the global fixed bottom UI (nav + mini player), so there we
reserve its real measured height (`bottomUiHeight`, observed live in the
root layout) plus a little breathing room. Non-Android reserves none —
the in-flow bar below owns that space. -->
<main <main
class="flex-1 overflow-y-auto p-4" class="flex-1 overflow-y-auto p-4 min-h-0"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? ($isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain" style="padding-bottom: {$isAndroid ? `calc(${$bottomUiHeight}px + 1rem)` : '1rem'}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll} onscroll={scrollGuard.onScroll}
> >
{@render children()} {@render children()}
</main> </main>
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) --> <!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
<!-- Hide on player page since full player is already there --> <!-- Hide on player page since full player is already there. Rendered in
normal flex flow so it sits above the list rather than overlapping it. -->
{#if !$isAndroid && !$page.url.pathname.startsWith('/player/')} {#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}
<MiniPlayer <MiniPlayer
media={$currentMedia} media={$currentMedia}
@ -225,6 +231,7 @@
{repeat} {repeat}
{hasNext} {hasNext}
{hasPrevious} {hasPrevious}
className="flex-shrink-0"
onExpand={() => showFullPlayer = true} onExpand={() => showFullPlayer = true}
onSleepTimerClick={() => showSleepTimerModal = true} onSleepTimerClick={() => showSleepTimerModal = true}
/> />