Files
jellytau/src/lib/components/player/AudioPlayer.svelte
T
dtourolle c55ff45692 fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.

None of the app's safe-area handling was ever active, for two independent
reasons:

  1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
     resolved to 0px — the padding in app.css and BottomUi was a no-op.
  2. Android WebView maps only the *display cutout* into `env()`; the status bar
     and navigation bar are never reported. With enableEdgeToEdge() and
     targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
     spans them, so CSS could not learn about them by any route.

WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.

Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.

The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.

Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
2026-08-04 14:45:10 +02:00

370 lines
12 KiB
Svelte

<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts">
import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
import {
mergedMedia,
mergedIsPlaying,
mergedPosition,
mergedDuration
} from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession } from "$lib/stores/sessions";
import { formatTime } from "$lib/utils/playbackUnits";
import Controls from "./Controls.svelte";
import Queue from "./Queue.svelte";
import CastButton from "$lib/components/sessions/CastButton.svelte";
import SleepTimerModal from "./SleepTimerModal.svelte";
import VolumeControl from "./VolumeControl.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { currentQueueItem } from "$lib/stores/queue";
interface Props {
media: MediaItem | null;
isPlaying?: boolean;
position?: number;
duration?: number;
shuffle?: boolean;
repeat?: "off" | "all" | "one";
hasNext?: boolean;
hasPrevious?: boolean;
onClose?: () => void;
}
let {
media,
isPlaying = false,
position = 0,
duration = 0,
shuffle = false,
repeat = "off",
hasNext = false,
hasPrevious = false,
onClose,
}: Props = $props();
let seeking = $state(false);
let seekValue = $state(0);
let seekPending = $state(false); // True while waiting for backend to confirm seek
let showSleepTimerModal = $state(false);
let showQueue = $state(false);
// Use merged media store for audio player display (handles both local and remote playback)
// In remote mode, this automatically uses the remote session's nowPlayingItem
// In local mode, falls back to queue item for complete metadata
const displayMedia = $derived($mergedMedia || $currentQueueItem);
const displayIsPlaying = $derived($mergedIsPlaying);
const rawPosition = $derived($mergedPosition);
const displayDuration = $derived($mergedDuration);
function handleSeekStart() {
seeking = true;
seekValue = rawPosition;
}
function handleSeekInput(e: Event) {
const target = e.target as HTMLInputElement;
seekValue = parseFloat(target.value);
}
async function handleSeekEnd() {
seeking = false;
seekPending = true; // Keep showing target position until backend catches up
await playerController.seek(seekValue);
}
// Control handlers for Controls component
async function handlePlayPause() {
await playerController.toggle();
}
async function handlePrevious() {
await playerController.previous();
}
async function handleNext() {
await playerController.next();
}
async function handleToggleShuffle() {
await playerController.toggleShuffle();
}
async function handleCycleRepeat() {
await playerController.cycleRepeat();
}
// Prefer album ID for artwork (all tracks in an album share the same cover)
// Falls back to track ID if no album ID available
const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
// Show optimistic position while seeking or waiting for backend confirmation
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
// Clear pending state when backend position catches up to our seek target
$effect(() => {
if (seekPending && Math.abs(rawPosition - seekValue) < 2) {
seekPending = false;
}
});
function navigateToArtist(artistId: string) {
onClose?.();
goto(`/library/${artistId}`);
}
function navigateToAlbum() {
const currentMedia = displayMedia;
if (currentMedia?.albumId) {
onClose?.();
goto(`/library/${currentMedia.albumId}`);
}
}
async function handleQueueItemClick(index: number) {
try {
queue.skipTo(index);
await playerController.skipTo(index);
} catch (e) {
console.error("Failed to skip to queue item:", e);
}
}
</script>
{#if displayMedia}
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
<!-- Background image (blurred) -->
{#if artworkItemId}
<div class="fixed inset-0 z-0">
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.imageId}
maxWidth={800}
alt=""
class="w-full h-full object-cover blur-3xl opacity-30"
/>
<div class="absolute inset-0 bg-gradient-to-b from-black/60 via-black/80 to-black"></div>
</div>
{:else}
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
{/if}
<!-- Content overlay. The blurred artwork behind it stays edge-to-edge; only
this layer is inset, so the close button clears the status bar and the
transport controls clear the Android gesture bar. (UR-066) -->
<div
class="relative z-10 flex flex-col h-full"
style:padding-top="var(--safe-top)"
style:padding-bottom="var(--safe-bottom)"
style:padding-left="var(--safe-left)"
style:padding-right="var(--safe-right)"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 flex-shrink-0">
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close player"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="flex flex-col items-center">
<p class="text-sm text-gray-400">Now Playing</p>
{#if $isRemoteMode && $selectedSession}
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
{$selectedSession.deviceName}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Cast Button -->
<CastButton size="md" />
<!-- Queue Button -->
<button
onclick={() => (showQueue = !showQueue)}
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue ? 'bg-white/10 text-[var(--color-jellyfin)]' : ''}"
title="Queue"
aria-label="Open queue"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
<button
onclick={() => (showSleepTimerModal = true)}
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
title="Sleep timer"
aria-label="Sleep timer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{#if $sleepTimerActive}
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
{/if}
</button>
<!-- Volume Control (Linux only) -->
<VolumeControl size="md" />
</div>
</div>
<!-- Artwork -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
{#if artworkItemId}
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.imageId}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<svg class="w-32 h-32 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
{/if}
</div>
</div>
<!-- Info & Controls -->
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<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">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
<button
onclick={() => navigateToArtist(artist.id)}
class="hover:text-white hover:underline transition-colors"
>
{artist.name}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
{/each}
{:else if displayMedia?.artists?.length}
<span>{displayMedia?.artists.join(", ")}</span>
{/if}
{#if displayMedia?.albumId && displayMedia?.albumName}
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
<span class="text-gray-500"></span>
{/if}
<button
onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors"
>
{displayMedia?.albumName}
</button>
{:else if displayMedia?.albumName}
<span>{displayMedia?.albumName}</span>
{/if}
</div>
</div>
<!-- Progress bar -->
<div class="space-y-2">
<input
type="range"
min="0"
max={displayDuration}
value={displayPosition}
oninput={handleSeekInput}
onmousedown={handleSeekStart}
ontouchstart={handleSeekStart}
onmouseup={handleSeekEnd}
ontouchend={handleSeekEnd}
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>{formatTime(displayPosition)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
<!-- Controls -->
<div class="flex justify-center">
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
onSleepTimerClick={() => (showSleepTimerModal = true)}
/>
</div>
</div>
</div> <!-- Close content overlay -->
</div>
{/if}
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => (showSleepTimerModal = false)}
/>
<!-- Queue Panel (slide up from bottom) -->
{#if showQueue}
<div class="fixed inset-0 z-[60]">
<!-- Backdrop -->
<button
type="button"
class="absolute inset-0 bg-black/50"
onclick={() => (showQueue = false)}
aria-label="Close queue"
></button>
<!-- Queue Panel. Slides up from the very bottom, so it owns the bottom
inset — its last row would otherwise sit under the gesture bar. -->
<div
class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up"
style:padding-bottom="var(--safe-bottom)"
>
<Queue
items={$queueItems}
currentIndex={$currentQueueIndex}
onItemClick={handleQueueItemClick}
onClose={() => (showQueue = false)}
/>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
</style>