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 {
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<ViewGroup>(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
}
}

View File

@ -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<android.app.Activity>? = 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})"
)
}
}

View File

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

View File

@ -677,12 +677,94 @@ fn specta_builder() -> Builder<tauri::Wry> {
}
#[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() {
// Initialize logger
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.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`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app

View File

@ -597,6 +597,13 @@ impl PlayerController {
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
pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle();

View File

@ -122,6 +122,18 @@ impl QueueManager {
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
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
if items.is_empty() {
@ -559,6 +571,21 @@ mod tests {
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
///
/// @req-test: UR-005 - Control media playback (skip to next track)

View File

@ -3,6 +3,10 @@
import { page } from '$app/stores';
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
function isActive(path: string): boolean {
const pathname = $page.url.pathname;
@ -15,7 +19,7 @@
</script>
<!-- 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">
<!-- Home 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
// ($lib/stores/queue), the single source of truth.
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
export const librarySearchQuery = writable("");

View File

@ -254,8 +254,9 @@ export const mergedVolume = derived(
* Should show audio miniplayer - state machine gated
* Only true when:
* 1. In remote mode with an active session playing media, OR
* 2. Player is in playing or paused state (not idle, loading, error)
* AND current media is audio (not video: Movie or Episode)
* 2. There is an audio item loaded/queued and we are not in a genuine
* stopped state. The bar stays visible through transient idle/loading/
* seeking blips so it never flickers while advancing between tracks.
*/
export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
@ -265,14 +266,6 @@ export const shouldShowAudioMiniPlayer = derived(
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
// item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type;
@ -280,7 +273,18 @@ export const shouldShowAudioMiniPlayer = derived(
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;
}
);

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 SleepTimerModal from "$lib/components/player/SleepTimerModal.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
// single source of truth (updated instantly on queue_changed).
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
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 () => {
// Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render.
@ -131,40 +167,43 @@
<!-- Toast notifications (global) -->
<Toast />
<!-- Bottom Navigation (mobile only - show everywhere except player and login) -->
{#if $isAuthenticated && !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
<BottomNav />
{/if}
<!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal
flow inside one measured wrapper. The wrapper's height is observed and
published to `bottomUiHeight` so pages reserve exactly this much space.
Mini player is first (visually on top, above the nav). -->
{#if showBottomNav || showGlobalMiniPlayer}
<div bind:this={bottomUiEl} class="fixed bottom-0 left-0 right-0 z-40 flex flex-col">
{#if showGlobalMiniPlayer}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
shuffle={$shuffle}
repeat={$repeat}
hasNext={$hasNext}
hasPrevious={$hasPrevious}
className="flex-shrink-0"
onExpand={() => {
// Navigate to player page when mini player is expanded
if ($currentMedia) {
goto(`/player/${$currentMedia.id}`);
}
}}
onSleepTimerClick={() => showSleepTimerModal.set(true)}
/>
{/if}
<!-- Mini Player - show everywhere except on full player page, login and settings -->
<!-- Android: Show on all routes (except player/login/settings) -->
<!-- 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
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
shuffle={$shuffle}
repeat={$repeat}
hasNext={$hasNext}
hasPrevious={$hasPrevious}
onExpand={() => {
// Navigate to player page when mini player is expanded
if ($currentMedia) {
goto(`/player/${$currentMedia.id}`);
}
}}
onSleepTimerClick={() => showSleepTimerModal.set(true)}
/>
{#if showBottomNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
{/if}
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
{/if}
{:else}
<div class="flex items-center justify-center h-screen">

View File

@ -5,9 +5,9 @@
import { commands } from "$lib/api/bindings";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
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 { isAndroid } from "$lib/stores/appState";
import { isAndroid, bottomUiHeight } from "$lib/stores/appState";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
@ -204,17 +204,23 @@
</div>
</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
class="flex-1 overflow-y-auto p-4"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? ($isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
class="flex-1 overflow-y-auto p-4 min-h-0"
style="padding-bottom: {$isAndroid ? `calc(${$bottomUiHeight}px + 1rem)` : '1rem'}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll}
>
{@render children()}
</main>
<!-- 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/')}
<MiniPlayer
media={$currentMedia}
@ -225,6 +231,7 @@
{repeat}
{hasNext}
{hasPrevious}
className="flex-shrink-0"
onExpand={() => showFullPlayer = true}
onSleepTimerClick={() => showSleepTimerModal = true}
/>