First working POC
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
<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>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
|
||||
interface Props {
|
||||
isPlaying?: boolean;
|
||||
hasPrevious?: boolean;
|
||||
hasNext?: boolean;
|
||||
shuffle?: boolean;
|
||||
repeat?: "off" | "all" | "one";
|
||||
onPlayPause?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
onToggleShuffle?: () => void;
|
||||
onCycleRepeat?: () => void;
|
||||
onSleepTimerClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isPlaying = false,
|
||||
hasPrevious = false,
|
||||
hasNext = false,
|
||||
shuffle = false,
|
||||
repeat = "off",
|
||||
onPlayPause,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onToggleShuffle,
|
||||
onCycleRepeat,
|
||||
onSleepTimerClick,
|
||||
}: Props = $props();
|
||||
|
||||
// Local optimistic state for instant button feedback
|
||||
let optimisticIsPlaying = $state(false);
|
||||
let optimisticTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Sync with prop changes (initializes and updates on prop change)
|
||||
$effect(() => {
|
||||
optimisticIsPlaying = isPlaying;
|
||||
// Clear timeout when prop updates (state confirmed)
|
||||
if (optimisticTimeout) {
|
||||
clearTimeout(optimisticTimeout);
|
||||
optimisticTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (optimisticTimeout) {
|
||||
clearTimeout(optimisticTimeout);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handlePlayPause() {
|
||||
// Immediately toggle optimistic state for instant visual feedback
|
||||
optimisticIsPlaying = !optimisticIsPlaying;
|
||||
|
||||
// Clear any pending timeout
|
||||
if (optimisticTimeout) {
|
||||
clearTimeout(optimisticTimeout);
|
||||
}
|
||||
|
||||
// Reset optimistic state after a delay if prop doesn't update
|
||||
optimisticTimeout = setTimeout(() => {
|
||||
optimisticIsPlaying = isPlaying;
|
||||
}, 1000);
|
||||
|
||||
// Call the actual handler
|
||||
untrack(() => onPlayPause?.());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Shuffle -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleShuffle?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-2 rounded-full transition-colors {shuffle
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
title="Shuffle"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Sleep Timer Indicator -->
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
|
||||
<!-- Previous -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPrevious?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
disabled={!hasPrevious}
|
||||
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Previous"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePlayPause();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-3 rounded-full bg-white text-black hover:scale-105 transition-transform"
|
||||
title={optimisticIsPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{#if optimisticIsPlaying}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Next -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNext?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
disabled={!hasNext}
|
||||
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Next"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Repeat -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCycleRepeat?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-2 rounded-full transition-colors {repeat !== 'off'
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
title="Repeat: {repeat}"
|
||||
>
|
||||
{#if repeat === "one"}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* MiniPlayer component - Always-visible bottom bar audio player
|
||||
*
|
||||
* Shows current track, playback controls, and progress for audio content.
|
||||
* Automatically hides for video content (Movie/Episode).
|
||||
* Supports both local and remote playback modes.
|
||||
*
|
||||
* @req: UR-005 - Control media playback (pause, play, skip, scrub)
|
||||
* @req: DR-009 - Audio player UI (mini player)
|
||||
* @req: UR-028 - Navigate to artist/album by tapping names in now playing view
|
||||
* @req: UR-017 - Like or unlike audio, albums, movies, etc.
|
||||
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||
*/
|
||||
|
||||
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 {
|
||||
mergedMedia,
|
||||
mergedIsPlaying,
|
||||
mergedPosition,
|
||||
mergedDuration,
|
||||
shouldShowAudioMiniPlayer
|
||||
} from "$lib/stores/player";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
import { selectedSession } from "$lib/stores/sessions";
|
||||
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import Controls from "./Controls.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import VolumeControl from "./VolumeControl.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
isPlaying?: boolean;
|
||||
position?: number;
|
||||
duration?: number;
|
||||
shuffle?: boolean;
|
||||
repeat?: "off" | "all" | "one";
|
||||
hasNext?: boolean;
|
||||
hasPrevious?: boolean;
|
||||
onExpand?: () => void;
|
||||
onSleepTimerClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
media,
|
||||
isPlaying = false,
|
||||
position = 0,
|
||||
duration = 0,
|
||||
shuffle = false,
|
||||
repeat = "off",
|
||||
hasNext = false,
|
||||
hasPrevious = false,
|
||||
onExpand,
|
||||
onSleepTimerClick,
|
||||
className = "",
|
||||
}: Props = $props();
|
||||
|
||||
// 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
|
||||
const displayMedia = $derived($mergedMedia || $currentQueueItem);
|
||||
const displayIsPlaying = $derived($mergedIsPlaying);
|
||||
const displayPosition = $derived($mergedPosition);
|
||||
const displayDuration = $derived($mergedDuration);
|
||||
|
||||
// State machine gated visibility - only show when player is playing/paused AND media is audio
|
||||
const shouldShow = $derived($shouldShowAudioMiniPlayer);
|
||||
|
||||
const progress = $derived(
|
||||
calculateProgress(displayPosition, displayDuration)
|
||||
);
|
||||
|
||||
function navigateToArtist(event: MouseEvent, artistId: string) {
|
||||
event.stopPropagation();
|
||||
goto(`/library/${artistId}`);
|
||||
}
|
||||
|
||||
function navigateToAlbum(event: MouseEvent) {
|
||||
const currentMedia = displayMedia;
|
||||
if (currentMedia?.albumId) {
|
||||
event.stopPropagation();
|
||||
goto(`/library/${currentMedia.albumId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe gesture state
|
||||
let touchStartX = $state(0);
|
||||
let touchStartY = $state(0);
|
||||
let touchEndX = $state(0);
|
||||
let touchEndY = $state(0);
|
||||
let isSwiping = $state(false);
|
||||
let swipeTransform = $state(0);
|
||||
|
||||
// Overflow menu state
|
||||
let showOverflowMenu = $state(false);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Scrubbing (seek) handler
|
||||
async function handleSeek(e: MouseEvent) {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = x / rect.width;
|
||||
const newPosition = percent * displayDuration;
|
||||
|
||||
try {
|
||||
await invoke("player_seek", { position: newPosition });
|
||||
haptics.tap();
|
||||
} catch (err) {
|
||||
console.error("Failed to seek:", err);
|
||||
toast.show("Failed to seek", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe gesture handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
isSwiping = true;
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isSwiping) return;
|
||||
touchEndX = e.touches[0].clientX;
|
||||
touchEndY = e.touches[0].clientY;
|
||||
|
||||
const diffX = touchStartX - touchEndX;
|
||||
const diffY = touchStartY - touchEndY;
|
||||
|
||||
// Only transform if horizontal swipe is dominant
|
||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||
swipeTransform = -diffX;
|
||||
// Prevent default to stop scrolling
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
|
||||
const diffX = touchStartX - touchEndX;
|
||||
const diffY = touchStartY - touchEndY;
|
||||
const swipeThreshold = 80;
|
||||
const minSwipeDistance = 20; // Minimum distance to be considered a swipe (not a tap)
|
||||
|
||||
// Only process if there was meaningful movement
|
||||
const totalDistance = Math.sqrt(diffX * diffX + diffY * diffY);
|
||||
if (totalDistance < minSwipeDistance) {
|
||||
// This was a tap, not a swipe - ignore it
|
||||
swipeTransform = 0;
|
||||
touchStartX = 0;
|
||||
touchStartY = 0;
|
||||
touchEndX = 0;
|
||||
touchEndY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine swipe direction
|
||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||
// Horizontal swipe
|
||||
if (Math.abs(diffX) > swipeThreshold) {
|
||||
if (diffX > 0) {
|
||||
// Swiped left - Next track
|
||||
haptics.tap();
|
||||
handleNext();
|
||||
toast.show("Next track", "info", 1000);
|
||||
} else {
|
||||
// Swiped right - Previous track
|
||||
haptics.tap();
|
||||
handlePrevious();
|
||||
toast.show("Previous track", "info", 1000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Vertical swipe
|
||||
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
|
||||
// Swiped up - Open full player
|
||||
console.log("[MiniPlayer] Swipe-up detected, expanding player");
|
||||
haptics.tap();
|
||||
onExpand?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transform
|
||||
swipeTransform = 0;
|
||||
touchStartX = 0;
|
||||
touchStartY = 0;
|
||||
touchEndX = 0;
|
||||
touchEndY = 0;
|
||||
}
|
||||
|
||||
// Overflow menu actions
|
||||
function handleAddToPlaylist() {
|
||||
showOverflowMenu = false;
|
||||
haptics.tap();
|
||||
toast.show("Add to playlist coming soon!", "info");
|
||||
}
|
||||
|
||||
function handleGoToAlbum() {
|
||||
showOverflowMenu = false;
|
||||
if (displayMedia?.albumId) {
|
||||
haptics.tap();
|
||||
goto(`/library/${displayMedia.albumId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoToArtist() {
|
||||
showOverflowMenu = false;
|
||||
if (displayMedia?.artistItems?.[0]?.id) {
|
||||
haptics.tap();
|
||||
goto(`/library/${displayMedia.artistItems[0].id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
showOverflowMenu = false;
|
||||
haptics.tap();
|
||||
toast.show("Share coming soon!", "info");
|
||||
}
|
||||
|
||||
function handleViewQueue() {
|
||||
showOverflowMenu = false;
|
||||
haptics.tap();
|
||||
toast.show("Queue view coming soon!", "info");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldShow && displayMedia}
|
||||
<div class="{className || 'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]">
|
||||
<!-- Remote Mode Indicator -->
|
||||
{#if $isRemoteMode && $selectedSession}
|
||||
<div class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" 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>
|
||||
<span class="text-xs text-[var(--color-jellyfin)] font-medium">
|
||||
Playing on {$selectedSession.deviceName}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar (clickable for scrubbing) -->
|
||||
<button
|
||||
onclick={handleSeek}
|
||||
class="h-1 bg-gray-700 w-full cursor-pointer hover:h-2 transition-all relative group"
|
||||
aria-label="Seek"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)] transition-all duration-100 pointer-events-none"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
<!-- Hover indicator -->
|
||||
<div class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="px-4 py-3 flex items-center gap-4 touch-pan-y relative"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
|
||||
>
|
||||
<!-- Media info -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<!-- Artwork (clickable to expand) -->
|
||||
<button
|
||||
onclick={onExpand}
|
||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||
aria-label="Open full player"
|
||||
>
|
||||
{#if displayMedia?.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={displayMedia.id}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
maxWidth={100}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-6 h-6" 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}
|
||||
</button>
|
||||
|
||||
<!-- Title & Artist -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<button
|
||||
onclick={onExpand}
|
||||
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
|
||||
>
|
||||
{displayMedia?.name}
|
||||
</button>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
<button
|
||||
onclick={(e) => navigateToArtist(e, 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>
|
||||
</div>
|
||||
|
||||
<!-- Favorite Button -->
|
||||
{#if displayMedia}
|
||||
<div class="hidden sm:block">
|
||||
<FavoriteButton
|
||||
itemId={displayMedia?.id ?? ""}
|
||||
isFavorite={displayMedia?.userData?.isFavorite ?? false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Button (visible on all screen sizes) -->
|
||||
<CastButton size="sm" />
|
||||
|
||||
<!-- Sleep Timer Indicator -->
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
<div class="hidden sm:block">
|
||||
<VolumeControl size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="text-xs text-gray-400 hidden sm:block">
|
||||
{formatTime(displayPosition)} / {formatTime(displayDuration)}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<Controls
|
||||
isPlaying={displayIsPlaying}
|
||||
{hasPrevious}
|
||||
{hasNext}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToggleShuffle={handleToggleShuffle}
|
||||
onCycleRepeat={handleCycleRepeat}
|
||||
{onSleepTimerClick}
|
||||
/>
|
||||
|
||||
<!-- Overflow Menu Button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => {
|
||||
showOverflowMenu = !showOverflowMenu;
|
||||
haptics.tap();
|
||||
}}
|
||||
class="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Overflow Menu Dropdown -->
|
||||
{#if showOverflowMenu}
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onclick={handleViewQueue}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" 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>
|
||||
View Queue
|
||||
</button>
|
||||
|
||||
{#if displayMedia?.albumId}
|
||||
<button
|
||||
onclick={handleGoToAlbum}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
Go to Album
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
<button
|
||||
onclick={handleGoToArtist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
Go to Artist
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleAddToPlaylist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleShare}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Click outside to close overflow menu -->
|
||||
{#if showOverflowMenu}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
nextEpisode,
|
||||
isNextEpisodePopupVisible,
|
||||
nextEpisodeItem,
|
||||
countdownSeconds,
|
||||
initialCountdownSeconds,
|
||||
isCountdownActive,
|
||||
} from "$lib/stores/nextEpisode";
|
||||
import {
|
||||
cancelAutoPlay,
|
||||
watchNextManually,
|
||||
} from "$lib/services/nextEpisodeService";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
|
||||
// Use series primary image for better visual consistency
|
||||
const imageId = $derived($nextEpisodeItem ? ($nextEpisodeItem.seriesId || $nextEpisodeItem.id) : null);
|
||||
|
||||
// Format episode info (S1:E5)
|
||||
const episodeInfo = $derived.by(() => {
|
||||
const episode = $nextEpisodeItem;
|
||||
if (!episode) return "";
|
||||
|
||||
const season = episode.parentIndexNumber;
|
||||
const epNum = episode.indexNumber;
|
||||
|
||||
if (season !== undefined && epNum !== undefined) {
|
||||
return `S${season}:E${epNum}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Calculate progress for the countdown bar (1 to 0)
|
||||
const countdownProgress = $derived.by(() => {
|
||||
const initial = $initialCountdownSeconds;
|
||||
const current = $countdownSeconds;
|
||||
if (initial <= 0) return 0;
|
||||
return current / initial;
|
||||
});
|
||||
|
||||
function handlePlayNow() {
|
||||
if ($nextEpisodeItem) {
|
||||
watchNextManually($nextEpisodeItem);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
cancelAutoPlay();
|
||||
}
|
||||
|
||||
// Note: Countdown pause/resume on hover is not implemented
|
||||
// Backend controls countdown timing via CountdownTick events
|
||||
function handleMouseEnter() {
|
||||
// TODO: Could add visual feedback on hover
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
// TODO: Could remove visual feedback
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isNextEpisodePopupVisible && $nextEpisodeItem}
|
||||
<div
|
||||
class="fixed bottom-24 right-6 z-50 max-w-sm animate-slide-up"
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="dialog"
|
||||
aria-label="Next episode"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--color-surface)] rounded-xl shadow-2xl overflow-hidden border border-gray-700"
|
||||
>
|
||||
<!-- Episode Card -->
|
||||
<div class="flex gap-4 p-4">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
{#if imageId && $nextEpisodeItem.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={imageId}
|
||||
imageType="Primary"
|
||||
tag={$nextEpisodeItem.primaryImageTag}
|
||||
maxHeight={200}
|
||||
alt={$nextEpisodeItem.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Play icon overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400 mb-1">Up Next</p>
|
||||
<h3 class="text-sm font-semibold text-white truncate">
|
||||
{$nextEpisodeItem.name}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400">
|
||||
{$nextEpisodeItem.seriesName}
|
||||
{episodeInfo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-4 pb-4 flex gap-3">
|
||||
<!-- Cancel button -->
|
||||
<button
|
||||
onclick={handleCancel}
|
||||
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<!-- Play now button with countdown -->
|
||||
<button
|
||||
onclick={handlePlayNow}
|
||||
class="flex-1 px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg text-sm font-medium text-white transition-colors relative overflow-hidden"
|
||||
>
|
||||
{#if $isCountdownActive}
|
||||
<!-- Countdown progress bar -->
|
||||
<div
|
||||
class="absolute inset-0 bg-white/20 origin-left transition-transform duration-1000 ease-linear"
|
||||
style="transform: scaleX({countdownProgress})"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<span class="relative">
|
||||
{#if $isCountdownActive}
|
||||
Play in {$countdownSeconds}s
|
||||
{:else}
|
||||
Play Now
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { queue } from "$lib/stores/queue";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
currentIndex?: number | null;
|
||||
onItemClick?: (index: number) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
currentIndex = null,
|
||||
onItemClick,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
// Add unique IDs for dnd-zone (required)
|
||||
interface DndItem extends MediaItem {
|
||||
dndId: string;
|
||||
}
|
||||
|
||||
let dndItems = $derived<DndItem[]>(
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
dndId: `${item.id}-${index}`,
|
||||
}))
|
||||
);
|
||||
|
||||
let dragDisabled = $state(true);
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
|
||||
const { items: newItems, info } = e.detail;
|
||||
// Update local state during drag
|
||||
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
|
||||
dragDisabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFinalize(e: CustomEvent<{ items: DndItem[]; info: { source: string } }>) {
|
||||
const { items: newItems, info } = e.detail;
|
||||
|
||||
// Find the moved item by comparing old and new positions
|
||||
const oldIds = dndItems.map(i => i.dndId);
|
||||
const newIds = newItems.map(i => i.dndId);
|
||||
|
||||
// Find indices that changed
|
||||
let fromIndex = -1;
|
||||
let toIndex = -1;
|
||||
|
||||
for (let i = 0; i < oldIds.length; i++) {
|
||||
if (oldIds[i] !== newIds[i]) {
|
||||
if (fromIndex === -1) {
|
||||
// Find where the item at this position came from
|
||||
fromIndex = oldIds.indexOf(newIds[i]);
|
||||
toIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
try {
|
||||
// Optimistic update
|
||||
queue.moveInQueue(fromIndex, toIndex);
|
||||
|
||||
// Sync with backend
|
||||
await invoke("player_move_in_queue", {
|
||||
fromIndex,
|
||||
toIndex,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to move queue item:", e);
|
||||
// The store already updated optimistically, refresh if needed
|
||||
}
|
||||
}
|
||||
|
||||
if (info.source === SOURCES.POINTER) {
|
||||
dragDisabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(e: Event) {
|
||||
e.preventDefault();
|
||||
dragDisabled = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.key === "Enter" || e.key === " ") && dragDisabled) {
|
||||
dragDisabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(e: Event, index: number) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
queue.removeFromQueue(index);
|
||||
await invoke("player_remove_from_queue", { index });
|
||||
} catch (err) {
|
||||
console.error("Failed to remove from queue:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">Queue ({items.length})</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close queue"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
{#if items.length === 0}
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<p>Queue is empty</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul
|
||||
use:dndzone={{
|
||||
items: dndItems,
|
||||
flipDurationMs,
|
||||
dragDisabled,
|
||||
dropTargetStyle: {},
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
class="list-none p-0 m-0"
|
||||
>
|
||||
{#each dndItems as item, index (item.dndId)}
|
||||
<li class="outline-none">
|
||||
<div
|
||||
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex === index ? 'bg-white/10' : ''}"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag to reorder"
|
||||
class="p-1 cursor-grab touch-none text-gray-500 hover:text-white transition-colors"
|
||||
onmousedown={startDrag}
|
||||
ontouchstart={startDrag}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Clickable area for track selection -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onItemClick?.(index)}
|
||||
class="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||
aria-label="Play {item.name}"
|
||||
>
|
||||
<!-- Index or playing indicator -->
|
||||
<div class="w-6 text-center flex-shrink-0">
|
||||
{#if currentIndex === index}
|
||||
<svg class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-sm text-gray-500">{index + 1}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Artwork -->
|
||||
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
||||
{#if item.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
maxWidth={80}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
||||
{item.name}
|
||||
</p>
|
||||
{#if item.artists?.length}
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{item.artists.join(", ")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatDuration(item.runTimeTicks)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleRemove(e, index)}
|
||||
class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||
aria-label="Remove from queue"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
sleepTimerActive,
|
||||
sleepTimerMode,
|
||||
sleepTimerRemainingSeconds,
|
||||
} from "$lib/stores/sleepTimer";
|
||||
import { formatTime } from "$lib/utils/playbackUnits";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
let { onClick }: Props = $props();
|
||||
|
||||
function getDisplayText(): string {
|
||||
const mode = $sleepTimerMode;
|
||||
switch (mode.kind) {
|
||||
case "time":
|
||||
return formatTime($sleepTimerRemainingSeconds);
|
||||
case "endOfTrack":
|
||||
return "End";
|
||||
case "episodes":
|
||||
return `${mode.remaining} ep`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $sleepTimerActive}
|
||||
<button
|
||||
onclick={onClick}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full bg-[var(--color-jellyfin)]/20 text-[var(--color-jellyfin)] text-xs font-medium hover:bg-[var(--color-jellyfin)]/30 transition-colors"
|
||||
title="Sleep timer active - click to modify"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{getDisplayText()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
sleepTimer,
|
||||
sleepTimerMode,
|
||||
sleepTimerActive,
|
||||
} from "$lib/stores/sleepTimer";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { isOpen = false, onClose }: Props = $props();
|
||||
|
||||
const timePresets = [15, 30, 45, 60];
|
||||
const episodePresets = [1, 2, 3];
|
||||
|
||||
const isEpisode = $derived($currentQueueItem?.type === "Episode");
|
||||
const isVideo = $derived(
|
||||
$currentQueueItem?.type === "Episode" || $currentQueueItem?.type === "Movie"
|
||||
);
|
||||
|
||||
function handleTimePreset(minutes: number) {
|
||||
sleepTimer.setTimeTimer(minutes);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleEndOfTrack() {
|
||||
sleepTimer.setEndOfTrackTimer();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleEpisodePreset(count: number) {
|
||||
sleepTimer.setEpisodesTimer(count);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
sleepTimer.cancel();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveLabel(): string {
|
||||
const mode = $sleepTimerMode;
|
||||
switch (mode.kind) {
|
||||
case "time":
|
||||
return "Timer active";
|
||||
case "endOfTrack":
|
||||
return "Stops after current";
|
||||
case "episodes":
|
||||
return `${mode.remaining} episode${mode.remaining !== 1 ? "s" : ""} remaining`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getEndOfTrackLabel(): string {
|
||||
const type = $currentQueueItem?.type;
|
||||
if (type === "Episode") return "End of current episode";
|
||||
if (type === "Movie") return "End of current film";
|
||||
return "End of current track";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sleep-timer-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--color-surface)] rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[80vh] sm:max-h-[70vh] flex flex-col shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="none"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="px-6 py-4 border-b border-gray-800 flex items-center justify-between"
|
||||
>
|
||||
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">
|
||||
Sleep Timer
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<!-- Active timer indicator -->
|
||||
{#if $sleepTimerActive}
|
||||
<div
|
||||
class="mb-4 p-4 rounded-lg bg-[var(--color-jellyfin)]/10 border border-[var(--color-jellyfin)]/30"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-[var(--color-jellyfin)]">
|
||||
{getActiveLabel()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleCancel}
|
||||
class="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time presets -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each timePresets as minutes}
|
||||
<button
|
||||
onclick={() => handleTimePreset(minutes)}
|
||||
class="p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-center"
|
||||
>
|
||||
<span class="text-lg font-medium text-white">{minutes}</span>
|
||||
<span class="text-sm text-gray-400 ml-1">min</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End of current track/episode/film -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after current</h3>
|
||||
<button
|
||||
onclick={handleEndOfTrack}
|
||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
<span class="text-white">{getEndOfTrackLabel()}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Episode countdown (only for TV episodes) -->
|
||||
{#if isEpisode}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">
|
||||
Stop after episodes
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each episodePresets as count}
|
||||
<button
|
||||
onclick={() => handleEpisodePreset(count)}
|
||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-white"
|
||||
>{count} more episode{count !== 1 ? "s" : ""}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock video element for testing seek behavior
|
||||
function createMockVideoElement(options: {
|
||||
paused?: boolean;
|
||||
autoplay?: boolean;
|
||||
currentTime?: number;
|
||||
} = {}) {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
|
||||
return {
|
||||
paused: options.paused ?? true,
|
||||
autoplay: options.autoplay ?? true,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
|
||||
pause: vi.fn(function(this: any) {
|
||||
this.paused = true;
|
||||
}),
|
||||
|
||||
play: vi.fn(function(this: any) {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (!listeners[event]) listeners[event] = [];
|
||||
listeners[event].push(handler);
|
||||
}),
|
||||
|
||||
removeEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (listeners[event]) {
|
||||
listeners[event] = listeners[event].filter(h => h !== handler);
|
||||
}
|
||||
}),
|
||||
|
||||
// Helper to trigger events in tests
|
||||
_triggerEvent: (event: string) => {
|
||||
listeners[event]?.forEach(h => h());
|
||||
},
|
||||
|
||||
_getListeners: () => listeners,
|
||||
};
|
||||
}
|
||||
|
||||
describe("VideoPlayer Resume Logic", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("handleCanPlay seek behavior", () => {
|
||||
it("should pause video before seeking to prevent autoplay from starting at position 0", async () => {
|
||||
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
|
||||
|
||||
// Simulate the handleCanPlay logic
|
||||
const initialPosition = 60;
|
||||
const hasPerformedInitialSeek = false;
|
||||
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
const wasPlaying = !videoElement.paused;
|
||||
videoElement.pause();
|
||||
|
||||
expect(videoElement.pause).toHaveBeenCalled();
|
||||
expect(wasPlaying).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should set currentTime to initial position", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 120;
|
||||
|
||||
videoElement.currentTime = initialPosition;
|
||||
|
||||
expect(videoElement.currentTime).toBe(120);
|
||||
});
|
||||
|
||||
it("should wait for seeked event before resuming playback", async () => {
|
||||
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
|
||||
const initialPosition = 60;
|
||||
|
||||
// Simulate handleCanPlay logic
|
||||
videoElement.pause();
|
||||
videoElement.currentTime = initialPosition;
|
||||
|
||||
// Create the promise that waits for seeked
|
||||
const seekPromise = new Promise<void>((resolve) => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener("seeked", onSeeked);
|
||||
resolve();
|
||||
};
|
||||
videoElement.addEventListener("seeked", onSeeked);
|
||||
});
|
||||
|
||||
// Verify listener was added
|
||||
expect(videoElement.addEventListener).toHaveBeenCalledWith("seeked", expect.any(Function));
|
||||
|
||||
// Simulate seek completion
|
||||
videoElement._triggerEvent("seeked");
|
||||
|
||||
await seekPromise;
|
||||
|
||||
// Verify listener was removed after seek
|
||||
expect(videoElement.removeEventListener).toHaveBeenCalledWith("seeked", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should resume playback after seek completes when autoplay is enabled", async () => {
|
||||
const videoElement = createMockVideoElement({ paused: false, autoplay: true });
|
||||
const initialPosition = 60;
|
||||
|
||||
// Simulate handleCanPlay logic
|
||||
const wasPlaying = !videoElement.paused;
|
||||
videoElement.pause();
|
||||
videoElement.currentTime = initialPosition;
|
||||
|
||||
// Wait for seeked
|
||||
const seekPromise = new Promise<void>((resolve) => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener("seeked", onSeeked);
|
||||
resolve();
|
||||
};
|
||||
videoElement.addEventListener("seeked", onSeeked);
|
||||
});
|
||||
|
||||
videoElement._triggerEvent("seeked");
|
||||
await seekPromise;
|
||||
|
||||
// Resume playback
|
||||
if (wasPlaying || videoElement.autoplay) {
|
||||
await videoElement.play();
|
||||
}
|
||||
|
||||
expect(videoElement.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not resume playback if video was paused and has no autoplay", async () => {
|
||||
const videoElement = createMockVideoElement({ paused: true, autoplay: false });
|
||||
const initialPosition = 60;
|
||||
|
||||
const wasPlaying = !videoElement.paused;
|
||||
videoElement.pause();
|
||||
videoElement.currentTime = initialPosition;
|
||||
|
||||
// Resume playback check
|
||||
if (wasPlaying || videoElement.autoplay) {
|
||||
await videoElement.play();
|
||||
}
|
||||
|
||||
expect(videoElement.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should have fallback timeout in case seeked event doesn't fire", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 60;
|
||||
|
||||
videoElement.currentTime = initialPosition;
|
||||
|
||||
let resolved = false;
|
||||
const seekPromise = new Promise<void>((resolve) => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener("seeked", onSeeked);
|
||||
resolve();
|
||||
};
|
||||
videoElement.addEventListener("seeked", onSeeked);
|
||||
|
||||
// Fallback timeout
|
||||
setTimeout(() => {
|
||||
videoElement.removeEventListener("seeked", onSeeked);
|
||||
resolved = true;
|
||||
resolve();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Don't trigger seeked event - rely on timeout
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
await seekPromise;
|
||||
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("should not seek if initialPosition is 0", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 0;
|
||||
const hasPerformedInitialSeek = false;
|
||||
|
||||
let seekPerformed = false;
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
seekPerformed = true;
|
||||
}
|
||||
|
||||
expect(seekPerformed).toBe(false);
|
||||
});
|
||||
|
||||
it("should not seek if hasPerformedInitialSeek is true", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 60;
|
||||
const hasPerformedInitialSeek = true;
|
||||
|
||||
let seekPerformed = false;
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
seekPerformed = true;
|
||||
}
|
||||
|
||||
expect(seekPerformed).toBe(false);
|
||||
});
|
||||
|
||||
it("should not seek if videoElement is null", () => {
|
||||
const videoElement = null;
|
||||
const initialPosition = 60;
|
||||
const hasPerformedInitialSeek = false;
|
||||
|
||||
let seekPerformed = false;
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
seekPerformed = true;
|
||||
}
|
||||
|
||||
expect(seekPerformed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPerformedInitialSeek flag", () => {
|
||||
it("should be set to true after seek is initiated", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 60;
|
||||
let hasPerformedInitialSeek = false;
|
||||
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
hasPerformedInitialSeek = true;
|
||||
videoElement.currentTime = initialPosition;
|
||||
}
|
||||
|
||||
expect(hasPerformedInitialSeek).toBe(true);
|
||||
});
|
||||
|
||||
it("should be reset to false when streamUrl changes", () => {
|
||||
let hasPerformedInitialSeek = true;
|
||||
let currentStreamUrl = "url1";
|
||||
|
||||
// Simulate $effect when streamUrl changes
|
||||
const newStreamUrl = "url2";
|
||||
if (newStreamUrl !== currentStreamUrl) {
|
||||
currentStreamUrl = newStreamUrl;
|
||||
hasPerformedInitialSeek = false;
|
||||
}
|
||||
|
||||
expect(hasPerformedInitialSeek).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent duplicate seeks on multiple canplay events", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 60;
|
||||
let hasPerformedInitialSeek = false;
|
||||
let seekCount = 0;
|
||||
|
||||
// First canplay
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
hasPerformedInitialSeek = true;
|
||||
videoElement.currentTime = initialPosition;
|
||||
seekCount++;
|
||||
}
|
||||
|
||||
// Second canplay (shouldn't seek)
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
hasPerformedInitialSeek = true;
|
||||
videoElement.currentTime = initialPosition;
|
||||
seekCount++;
|
||||
}
|
||||
|
||||
expect(seekCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialPosition change handling", () => {
|
||||
it("should seek when initialPosition changes after initial seek was done", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
let hasPerformedInitialSeek = true;
|
||||
const isMediaReady = true;
|
||||
let currentTime = 60;
|
||||
|
||||
// Simulate new position
|
||||
const newPosition = 120;
|
||||
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
hasPerformedInitialSeek = false;
|
||||
videoElement.currentTime = newPosition;
|
||||
currentTime = newPosition;
|
||||
}
|
||||
|
||||
expect(videoElement.currentTime).toBe(120);
|
||||
expect(currentTime).toBe(120);
|
||||
expect(hasPerformedInitialSeek).toBe(false);
|
||||
});
|
||||
|
||||
it("should not seek if media is not ready", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const hasPerformedInitialSeek = true;
|
||||
const isMediaReady = false;
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
expect(seekTriggered).toBe(false);
|
||||
});
|
||||
|
||||
it("should not seek if initial seek hasn't been performed yet", () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const hasPerformedInitialSeek = false;
|
||||
const isMediaReady = true;
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
expect(seekTriggered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seekOffset handling for transcoded streams", () => {
|
||||
it("should reset seekOffset to 0 when streamUrl changes", () => {
|
||||
let seekOffset = 120;
|
||||
let currentStreamUrl = "url1";
|
||||
|
||||
// Simulate $effect when streamUrl changes
|
||||
const newStreamUrl = "url2";
|
||||
if (newStreamUrl !== currentStreamUrl) {
|
||||
currentStreamUrl = newStreamUrl;
|
||||
seekOffset = 0;
|
||||
}
|
||||
|
||||
expect(seekOffset).toBe(0);
|
||||
});
|
||||
|
||||
it("should add seekOffset to currentTime for transcoded streams", () => {
|
||||
const seekOffset = 60;
|
||||
const videoElementTime = 30; // Video thinks it's at 30s
|
||||
|
||||
const currentTime = seekOffset + videoElementTime;
|
||||
|
||||
expect(currentTime).toBe(90); // Actual position is 90s
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle seek errors gracefully", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
const initialPosition = 60;
|
||||
let errorCaught = false;
|
||||
|
||||
// Simulate a video element that throws on currentTime set
|
||||
Object.defineProperty(videoElement, 'currentTime', {
|
||||
set: () => { throw new Error('Seek not allowed'); },
|
||||
get: () => 0,
|
||||
});
|
||||
|
||||
try {
|
||||
videoElement.currentTime = initialPosition;
|
||||
} catch (err) {
|
||||
errorCaught = true;
|
||||
}
|
||||
|
||||
expect(errorCaught).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle play() rejection gracefully", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
|
||||
|
||||
let errorCaught = false;
|
||||
try {
|
||||
await videoElement.play();
|
||||
} catch (err) {
|
||||
errorCaught = true;
|
||||
}
|
||||
|
||||
expect(errorCaught).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resume Dialog Logic", () => {
|
||||
describe("progress eligibility", () => {
|
||||
it("should show resume dialog when watched > 30 seconds and < 90% complete", () => {
|
||||
const positionSeconds = 60;
|
||||
const totalSeconds = 3600; // 1 hour video
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
const shouldShow = positionSeconds > 30 && progressPercent < 90;
|
||||
|
||||
expect(shouldShow).toBe(true);
|
||||
});
|
||||
|
||||
it("should not show resume dialog when watched <= 30 seconds", () => {
|
||||
const positionSeconds = 25;
|
||||
const totalSeconds = 3600;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
const shouldShow = positionSeconds > 30 && progressPercent < 90;
|
||||
|
||||
expect(shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it("should not show resume dialog when >= 90% complete", () => {
|
||||
const positionSeconds = 3300; // 55 minutes of 1 hour video
|
||||
const totalSeconds = 3600;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
const shouldShow = positionSeconds > 30 && progressPercent < 90;
|
||||
|
||||
expect(shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle edge case at exactly 30 seconds", () => {
|
||||
const positionSeconds = 30;
|
||||
const totalSeconds = 3600;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
const shouldShow = positionSeconds > 30 && progressPercent < 90;
|
||||
|
||||
expect(shouldShow).toBe(false); // > 30, not >= 30
|
||||
});
|
||||
|
||||
it("should handle edge case at exactly 90%", () => {
|
||||
const positionSeconds = 3240; // Exactly 90% of 3600
|
||||
const totalSeconds = 3600;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
const shouldShow = positionSeconds > 30 && progressPercent < 90;
|
||||
|
||||
expect(shouldShow).toBe(false); // < 90, not <= 90
|
||||
});
|
||||
});
|
||||
|
||||
describe("position tick conversion", () => {
|
||||
it("should convert ticks to seconds correctly", () => {
|
||||
const positionTicks = 600_000_000; // 60 seconds in ticks
|
||||
const positionSeconds = positionTicks / 10_000_000;
|
||||
|
||||
expect(positionSeconds).toBe(60);
|
||||
});
|
||||
|
||||
it("should convert seconds to ticks correctly", () => {
|
||||
const positionSeconds = 120;
|
||||
const positionTicks = positionSeconds * 10_000_000;
|
||||
|
||||
expect(positionTicks).toBe(1_200_000_000);
|
||||
});
|
||||
|
||||
it("should handle large tick values", () => {
|
||||
const positionTicks = 36_000_000_000; // 1 hour in ticks
|
||||
const positionSeconds = positionTicks / 10_000_000;
|
||||
|
||||
expect(positionSeconds).toBe(3600);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { volume, isMuted } from "$lib/stores/player";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
interface Props {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
let { size = "md" }: Props = $props();
|
||||
|
||||
// On Android, volume is controlled by system volume buttons (not a slider)
|
||||
const isAndroid = platform() === "android";
|
||||
|
||||
let showSlider = $state(false);
|
||||
let sliderValue = $state($volume);
|
||||
|
||||
// Sync slider with store value
|
||||
$effect(() => {
|
||||
sliderValue = $volume;
|
||||
});
|
||||
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const newVolume = parseFloat(target.value);
|
||||
sliderValue = newVolume;
|
||||
await invoke("player_set_volume", { volume: newVolume });
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
await invoke("player_toggle_mute");
|
||||
}
|
||||
|
||||
function toggleSlider() {
|
||||
showSlider = !showSlider;
|
||||
}
|
||||
|
||||
// Icon sizes based on prop (use $derived for reactivity)
|
||||
const iconSize = $derived(size === "sm" ? "w-4 h-4" : size === "md" ? "w-5 h-5" : "w-6 h-6");
|
||||
const buttonPadding = $derived(size === "sm" ? "p-1" : size === "md" ? "p-2" : "p-3");
|
||||
</script>
|
||||
|
||||
{#if !isAndroid}
|
||||
<div class="relative flex items-center gap-1">
|
||||
<!-- Volume Icon Button (click to toggle slider) -->
|
||||
<button
|
||||
onclick={toggleSlider}
|
||||
class="{buttonPadding} rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Volume"
|
||||
>
|
||||
{#if $isMuted || sliderValue === 0}
|
||||
<!-- Muted Icon -->
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
{:else if sliderValue < 0.33}
|
||||
<!-- Low Volume Icon -->
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072m-9.95-9.193L4 8.929V5.071a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if sliderValue < 0.66}
|
||||
<!-- Medium Volume Icon -->
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072M6.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h2.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L6.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- High Volume Icon -->
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Volume Slider (toggle on click) -->
|
||||
{#if showSlider}
|
||||
<div
|
||||
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
|
||||
role="group"
|
||||
aria-label="Volume controls"
|
||||
>
|
||||
<!-- Mute button inside slider popup -->
|
||||
<button
|
||||
onclick={toggleMute}
|
||||
class="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title={$isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{#if $isMuted || sliderValue === 0}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={sliderValue}
|
||||
oninput={handleVolumeChange}
|
||||
class="w-24 h-1 accent-[var(--color-jellyfin)] cursor-pointer"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sliderValue * 100)}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Click outside to close volume slider -->
|
||||
{#if showSlider}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showSlider = false}
|
||||
aria-label="Close volume"
|
||||
></button>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user