356 lines
12 KiB
Svelte
356 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { goto } from "$app/navigation";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
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 invoke("player_seek", { position: seekValue });
|
|
}
|
|
|
|
// Control handlers for Controls component
|
|
async function handlePlayPause() {
|
|
await invoke("player_toggle");
|
|
}
|
|
|
|
async function handlePrevious() {
|
|
await invoke("player_previous");
|
|
}
|
|
|
|
async function handleNext() {
|
|
await invoke("player_next");
|
|
}
|
|
|
|
async function handleToggleShuffle() {
|
|
await invoke("player_toggle_shuffle");
|
|
}
|
|
|
|
async function handleCycleRepeat() {
|
|
await invoke("player_cycle_repeat");
|
|
}
|
|
|
|
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
|
|
// Album art is inherited from album, so all tracks show the same album cover
|
|
const artworkItemId = $derived(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 invoke("player_skip_to", { 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 && displayMedia?.primaryImageTag}
|
|
<div class="fixed inset-0 z-0">
|
|
<CachedImage
|
|
itemId={artworkItemId}
|
|
imageType="Primary"
|
|
tag={displayMedia.primaryImageTag}
|
|
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 -->
|
|
<div class="relative z-10 flex flex-col h-full">
|
|
<!-- 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="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</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 && displayMedia?.primaryImageTag}
|
|
<CachedImage
|
|
itemId={artworkItemId}
|
|
imageType="Primary"
|
|
tag={displayMedia.primaryImageTag}
|
|
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">{displayMedia?.name}</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 -->
|
|
<div class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up">
|
|
<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>
|