Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
391 lines
13 KiB
Svelte
391 lines
13 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";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("AudioPlayer");
|
|
|
|
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) {
|
|
log.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>
|