chore(format): run prettier over src/ and scripts/
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.
This commit is contained in:
@@ -6,12 +6,7 @@
|
||||
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 { 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";
|
||||
@@ -167,167 +162,190 @@
|
||||
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 -->
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<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"
|
||||
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="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</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>
|
||||
<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}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
<VolumeControl size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Cast Button -->
|
||||
<CastButton size="md" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 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={() => (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={() => navigateToArtist(artist.id)}
|
||||
onclick={navigateToAlbum}
|
||||
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>
|
||||
{displayMedia?.albumName}
|
||||
</button>
|
||||
{:else if displayMedia?.albumName}
|
||||
<span>{displayMedia?.albumName}</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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
</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)}
|
||||
/>
|
||||
<!-- 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>
|
||||
</div> <!-- Close content overlay -->
|
||||
<!-- Close content overlay -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SleepTimerModal
|
||||
isOpen={showSleepTimerModal}
|
||||
onClose={() => (showSleepTimerModal = false)}
|
||||
/>
|
||||
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => (showSleepTimerModal = false)} />
|
||||
|
||||
<!-- Queue Panel (slide up from bottom) -->
|
||||
{#if showQueue}
|
||||
|
||||
@@ -90,8 +90,12 @@
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<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="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" />
|
||||
<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>
|
||||
</button>
|
||||
{:else}
|
||||
@@ -113,7 +117,9 @@
|
||||
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" />
|
||||
<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>
|
||||
|
||||
@@ -192,7 +198,9 @@
|
||||
>
|
||||
{#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" />
|
||||
<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">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
mergedIsPlaying,
|
||||
mergedPosition,
|
||||
mergedDuration,
|
||||
shouldShowAudioMiniPlayer
|
||||
shouldShowAudioMiniPlayer,
|
||||
} from "$lib/stores/player";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
@@ -101,9 +101,7 @@
|
||||
// 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)
|
||||
);
|
||||
const progress = $derived(calculateProgress(displayPosition, displayDuration));
|
||||
|
||||
function navigateToArtist(event: MouseEvent, artistId: string) {
|
||||
event.stopPropagation();
|
||||
@@ -284,12 +282,23 @@
|
||||
</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]">
|
||||
<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" />
|
||||
<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}
|
||||
@@ -308,7 +317,9 @@
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
<!-- Hover indicator -->
|
||||
<div class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
></div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
@@ -317,14 +328,14 @@
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping
|
||||
? 'none'
|
||||
: 'transform 0.3s ease-out'}"
|
||||
>
|
||||
<!-- Row 1: Media info, like, cast, overflow -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Artwork -->
|
||||
<div
|
||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||
>
|
||||
<div class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
||||
{#if displayMedia}
|
||||
<CachedImage
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
@@ -337,7 +348,9 @@
|
||||
{: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" />
|
||||
<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}
|
||||
@@ -345,9 +358,7 @@
|
||||
|
||||
<!-- Title & Artist -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||
>
|
||||
<div class="text-sm font-medium text-white truncate block w-full text-left">
|
||||
{truncateMiddle(displayMedia?.name, 40)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
@@ -381,11 +392,7 @@
|
||||
|
||||
<!-- Like Button -->
|
||||
{#if displayMedia}
|
||||
<FavoriteButton
|
||||
itemId={displayMedia?.id ?? ""}
|
||||
bind:isFavorite
|
||||
size="sm"
|
||||
/>
|
||||
<FavoriteButton itemId={displayMedia?.id ?? ""} bind:isFavorite size="sm" />
|
||||
{/if}
|
||||
|
||||
<!-- Cast Button -->
|
||||
@@ -402,7 +409,9 @@
|
||||
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"/>
|
||||
<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>
|
||||
|
||||
@@ -417,7 +426,12 @@
|
||||
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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
View Queue
|
||||
</button>
|
||||
@@ -428,7 +442,9 @@
|
||||
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"/>
|
||||
<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>
|
||||
@@ -440,7 +456,9 @@
|
||||
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"/>
|
||||
<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>
|
||||
@@ -451,7 +469,12 @@
|
||||
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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
@@ -461,7 +484,12 @@
|
||||
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" />
|
||||
<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>
|
||||
@@ -508,7 +536,7 @@
|
||||
{#if showOverflowMenu}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
onclick={() => (showOverflowMenu = false)}
|
||||
aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
initialCountdownSeconds,
|
||||
isCountdownActive,
|
||||
} from "$lib/stores/nextEpisode";
|
||||
import {
|
||||
cancelAutoPlay,
|
||||
watchNextManually,
|
||||
} from "$lib/services/nextEpisodeService";
|
||||
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);
|
||||
const imageId = $derived(
|
||||
$nextEpisodeItem ? $nextEpisodeItem.seriesId || $nextEpisodeItem.id : null,
|
||||
);
|
||||
|
||||
// Format episode info (S1:E5)
|
||||
const episodeInfo = $derived.by(() => {
|
||||
@@ -75,9 +74,7 @@
|
||||
<!-- 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"
|
||||
>
|
||||
<div class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800">
|
||||
{#if imageId && $nextEpisodeItem.imageId}
|
||||
<CachedImage
|
||||
itemId={imageId}
|
||||
@@ -90,14 +87,8 @@
|
||||
{/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"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
currentIndex = null,
|
||||
onItemClick,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
let { items, currentIndex = null, onItemClick, onClose }: Props = $props();
|
||||
|
||||
// Add unique IDs for dnd-zone (required)
|
||||
interface DndItem extends MediaItem {
|
||||
@@ -33,7 +28,7 @@
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
dndId: `${item.id}-${index}`,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
let dragDisabled = $state(true);
|
||||
@@ -47,7 +42,9 @@
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
|
||||
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) {
|
||||
@@ -59,8 +56,8 @@
|
||||
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);
|
||||
const oldIds = dndItems.map((i) => i.dndId);
|
||||
const newIds = newItems.map((i) => i.dndId);
|
||||
|
||||
// Find indices that changed
|
||||
let fromIndex = -1;
|
||||
@@ -126,7 +123,12 @@
|
||||
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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,7 +153,10 @@
|
||||
{#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' : ''}"
|
||||
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex ===
|
||||
index
|
||||
? 'bg-white/10'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<button
|
||||
@@ -163,7 +168,9 @@
|
||||
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"/>
|
||||
<path
|
||||
d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -177,7 +184,11 @@
|
||||
<!-- 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">
|
||||
<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}
|
||||
@@ -201,7 +212,11 @@
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
||||
<p
|
||||
class="text-sm font-medium truncate {currentIndex === index
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-white'}"
|
||||
>
|
||||
{truncateMiddle(item.name, 48)}
|
||||
</p>
|
||||
{#if item.artists?.length}
|
||||
@@ -225,7 +240,12 @@
|
||||
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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<!-- TRACES: UR-026 | DR-029, DR-050 -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
sleepTimer,
|
||||
sleepTimerMode,
|
||||
sleepTimerActive,
|
||||
} from "$lib/stores/sleepTimer";
|
||||
import { sleepTimer, sleepTimerMode, sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
|
||||
|
||||
@@ -96,7 +92,9 @@
|
||||
<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') onClose?.(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") onClose?.();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sleep-timer-title"
|
||||
@@ -108,23 +106,14 @@
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -176,7 +165,9 @@
|
||||
selectedValue={selectedMinutes}
|
||||
visibleCount={3}
|
||||
itemHeight={56}
|
||||
onSelect={(val) => { selectedMinutes = val as number; }}
|
||||
onSelect={(val) => {
|
||||
selectedMinutes = val as number;
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onclick={handleSetTimer}
|
||||
@@ -194,11 +185,7 @@
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
@@ -208,27 +195,19 @@
|
||||
<!-- Episode countdown (only for TV episodes) -->
|
||||
{#if isEpisode}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">
|
||||
Stop after episodes
|
||||
</h3>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<span class="text-white">{count} more episode{count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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;
|
||||
} = {}) {
|
||||
function createMockVideoElement(
|
||||
options: {
|
||||
paused?: boolean;
|
||||
autoplay?: boolean;
|
||||
currentTime?: number;
|
||||
} = {},
|
||||
) {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
|
||||
return {
|
||||
@@ -13,11 +15,11 @@ function createMockVideoElement(options: {
|
||||
autoplay: options.autoplay ?? true,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
|
||||
pause: vi.fn(function(this: any) {
|
||||
pause: vi.fn(function (this: any) {
|
||||
this.paused = true;
|
||||
}),
|
||||
|
||||
play: vi.fn(function(this: any) {
|
||||
play: vi.fn(function (this: any) {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
@@ -29,13 +31,13 @@ function createMockVideoElement(options: {
|
||||
|
||||
removeEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (listeners[event]) {
|
||||
listeners[event] = listeners[event].filter(h => h !== handler);
|
||||
listeners[event] = listeners[event].filter((h) => h !== handler);
|
||||
}
|
||||
}),
|
||||
|
||||
// Helper to trigger events in tests
|
||||
_triggerEvent: (event: string) => {
|
||||
listeners[event]?.forEach(h => h());
|
||||
listeners[event]?.forEach((h) => h());
|
||||
},
|
||||
|
||||
_getListeners: () => listeners,
|
||||
@@ -283,7 +285,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
// Simulate new position
|
||||
const newPosition = 120;
|
||||
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
hasPerformedInitialSeek = false;
|
||||
videoElement.currentTime = newPosition;
|
||||
currentTime = newPosition;
|
||||
@@ -301,7 +309,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
@@ -315,7 +329,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
@@ -355,8 +375,10 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
let errorCaught = false;
|
||||
|
||||
// Simulate a video element that throws on currentTime set
|
||||
Object.defineProperty(videoElement, 'currentTime', {
|
||||
set: () => { throw new Error('Seek not allowed'); },
|
||||
Object.defineProperty(videoElement, "currentTime", {
|
||||
set: () => {
|
||||
throw new Error("Seek not allowed");
|
||||
},
|
||||
get: () => 0,
|
||||
});
|
||||
|
||||
@@ -371,7 +393,7 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
|
||||
it("should handle play() rejection gracefully", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
|
||||
videoElement.play = vi.fn().mockRejectedValue(new Error("Autoplay blocked"));
|
||||
|
||||
let errorCaught = false;
|
||||
try {
|
||||
|
||||
@@ -146,9 +146,7 @@ async function mountNativePlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
|
||||
// if it were, these tests would be guarding the HTML5 path by accident.
|
||||
await waitFor(() =>
|
||||
expect(utils.container.querySelector("video")).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(utils.container.querySelector("video")).toBeNull());
|
||||
expect(playerStop).not.toHaveBeenCalled();
|
||||
return utils;
|
||||
}
|
||||
@@ -168,11 +166,7 @@ function poster(container: HTMLElement): HTMLElement | null {
|
||||
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
|
||||
* the store is what the component must read.
|
||||
*/
|
||||
async function backendReports(
|
||||
kind: "playing" | "paused" | "error",
|
||||
position = 0,
|
||||
duration = 0
|
||||
) {
|
||||
async function backendReports(kind: "playing" | "paused" | "error", position = 0, duration = 0) {
|
||||
const media = makeEpisode();
|
||||
if (kind === "playing") player.setPlaying(media, position, duration);
|
||||
else if (kind === "paused") player.setPaused(media, position, duration);
|
||||
@@ -219,7 +213,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
|
||||
await backendReports("paused", 5, 1440);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
|
||||
);
|
||||
|
||||
await backendReports("playing", 6, 1440);
|
||||
@@ -228,25 +222,21 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
// both dims and covers the ExoPlayer surface while it plays. Before the
|
||||
// mirror, nothing after init could take it down, because the only other
|
||||
// writer was the never-emitted `player://state-changed` channel.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
|
||||
});
|
||||
|
||||
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
|
||||
const { container } = await mountNativePlayer();
|
||||
|
||||
await backendReports("playing", 5, 1440);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
|
||||
|
||||
await backendReports("paused", 6, 1440);
|
||||
|
||||
// The mirror has to work in both directions, or pausing leaves no affordance
|
||||
// to resume.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -284,14 +274,18 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
// defeated the first attempt: a one-shot timer armed on entry fired here,
|
||||
// declined, and was never re-armed.
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain(
|
||||
"opacity-0",
|
||||
);
|
||||
|
||||
// Playback starts late; the countdown has to restart on its own.
|
||||
player.setPlaying(makeEpisode(), 5, 1440);
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain(
|
||||
"opacity-0",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -150,9 +150,7 @@ async function mountAndroidPlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
expect(video).not.toBeNull();
|
||||
@@ -160,11 +158,7 @@ async function mountAndroidPlayer() {
|
||||
}
|
||||
|
||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
||||
async function scrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
await fireEvent.mouseDown(slider);
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
@@ -201,8 +195,8 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
600,
|
||||
"src-1",
|
||||
null,
|
||||
true // HTML5 path: the webview owns playback after the override
|
||||
)
|
||||
true, // HTML5 path: the webview owns playback after the override
|
||||
),
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,22 @@
|
||||
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
||||
}
|
||||
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
|
||||
let {
|
||||
media,
|
||||
streamUrl,
|
||||
mediaSourceId,
|
||||
initialPosition,
|
||||
needsTranscoding = false,
|
||||
onClose,
|
||||
onSeek,
|
||||
onReportProgress,
|
||||
onReportStart,
|
||||
onReportStop,
|
||||
onEnded,
|
||||
onNext,
|
||||
hasNext = false,
|
||||
isLive = false,
|
||||
}: Props = $props();
|
||||
|
||||
// The id this player instance reports progress against. Snapshotted from the
|
||||
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
||||
@@ -140,12 +155,7 @@
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
@@ -242,8 +252,12 @@
|
||||
const adapterBridge: Html5ElementBridge = {
|
||||
getElement: () => videoElement,
|
||||
getSeekOffset: () => seekOffset,
|
||||
setSeekOffset: (o) => { seekOffset = o; },
|
||||
setStreamUrl: (u) => { currentStreamUrl = u; },
|
||||
setSeekOffset: (o) => {
|
||||
seekOffset = o;
|
||||
},
|
||||
setStreamUrl: (u) => {
|
||||
currentStreamUrl = u;
|
||||
},
|
||||
destroyHls: tearDownHls,
|
||||
getMediaSourceId: () => mediaSourceId ?? null,
|
||||
};
|
||||
@@ -278,7 +292,6 @@
|
||||
return videoDuration;
|
||||
});
|
||||
|
||||
|
||||
// The audio tracks available for this item, as the server described them.
|
||||
//
|
||||
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
|
||||
@@ -293,19 +306,22 @@
|
||||
log.debug("No media or mediaStreams available");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
||||
const tracks = media.mediaStreams.filter((stream) => stream.kind === "audio");
|
||||
log.debug("Found audio tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// Function to find best matching audio track based on preference
|
||||
function findBestAudioTrack(preference: { audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null }) {
|
||||
function findBestAudioTrack(preference: {
|
||||
audioTrackDisplayTitle?: string | null;
|
||||
audioTrackLanguage?: string | null;
|
||||
}) {
|
||||
const tracks = audioTracks();
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
// Try to match by display title first
|
||||
if (preference.audioTrackDisplayTitle) {
|
||||
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||
const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||
if (match) {
|
||||
log.debug("Matched audio track by display title:", match.displayTitle);
|
||||
return match.index;
|
||||
@@ -314,7 +330,7 @@
|
||||
|
||||
// Try to match by language
|
||||
if (preference.audioTrackLanguage) {
|
||||
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
||||
const match = tracks.find((t) => t.language === preference.audioTrackLanguage);
|
||||
if (match) {
|
||||
log.debug("Matched audio track by language:", match.language);
|
||||
return match.index;
|
||||
@@ -322,8 +338,11 @@
|
||||
}
|
||||
|
||||
// Fall back to default track
|
||||
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
||||
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
||||
const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
|
||||
log.debug(
|
||||
"Using default/first audio track:",
|
||||
defaultTrack.displayTitle || defaultTrack.language,
|
||||
);
|
||||
return defaultTrack.index;
|
||||
}
|
||||
|
||||
@@ -388,7 +407,7 @@
|
||||
// Cross-origin <track> fetches use the media element's CORS setting; see
|
||||
// videoCrossOriginMode for why this is opt-in and same-origin-only.
|
||||
const videoCrossOrigin = $derived(
|
||||
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
|
||||
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -408,7 +427,10 @@
|
||||
renderedSubtitleTracks = tracks;
|
||||
// Keep the menu's checkmark and the element's text tracks in agreement:
|
||||
// a selection that no longer resolves collapses to "Off".
|
||||
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
|
||||
const selected = reconcileSelectedSubtitle(
|
||||
tracks,
|
||||
untrack(() => selectedSubtitleIndex),
|
||||
);
|
||||
selectedSubtitleIndex = selected;
|
||||
// The <track> children were just (re)created, so re-apply the selection to
|
||||
// the new TextTrack objects — otherwise a surviving selection shows nothing.
|
||||
@@ -439,7 +461,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Sleep-timer expiry pause is now driven by the backend through the player
|
||||
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
|
||||
// pause() (see handleControlCommand / the sleep_timer_expired case). This
|
||||
@@ -545,12 +566,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isHlsStream = currentStreamUrl.includes('.m3u8');
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
|
||||
if (isHlsStream && Hls.isSupported()) {
|
||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||
if (hls) {
|
||||
log.debug('Cleaning up existing HLS instance');
|
||||
log.debug("Cleaning up existing HLS instance");
|
||||
// Detach from media element first to stop all audio/video
|
||||
hls.detachMedia();
|
||||
// Stop loading and flush buffers
|
||||
@@ -564,7 +585,7 @@
|
||||
// This is critical to prevent dual audio streams
|
||||
if (videoElement.src) {
|
||||
videoElement.pause(); // Ensure playback is stopped
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.removeAttribute("src");
|
||||
videoElement.load(); // Reset the media element and clear all buffers
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
@@ -574,7 +595,7 @@
|
||||
setTimeout(() => {
|
||||
if (!videoElement) return;
|
||||
|
||||
log.debug('Creating new HLS instance for:', currentStreamUrl);
|
||||
log.debug("Creating new HLS instance for:", currentStreamUrl);
|
||||
|
||||
// Create new HLS instance
|
||||
hls = new Hls({
|
||||
@@ -602,14 +623,14 @@
|
||||
|
||||
// Listen for media attached event
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
log.debug('HLS.js attached to video element');
|
||||
log.debug("HLS.js attached to video element");
|
||||
// Load the HLS stream
|
||||
hls!.loadSource(currentStreamUrl);
|
||||
});
|
||||
|
||||
// Listen for manifest parsed event
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
log.debug('HLS manifest parsed, ready to play');
|
||||
log.debug("HLS manifest parsed, ready to play");
|
||||
});
|
||||
|
||||
// On the Android WebView the element's own `canplay` may not fire for
|
||||
@@ -626,7 +647,11 @@
|
||||
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
||||
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||
log.warn(
|
||||
"HLS canplay fallback - revealing video (readyState:",
|
||||
videoElement.readyState,
|
||||
")",
|
||||
);
|
||||
markMediaReady();
|
||||
}
|
||||
}, 5000);
|
||||
@@ -636,7 +661,7 @@
|
||||
|
||||
// Handle errors
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
log.error('HLS error:', data);
|
||||
log.error("HLS error:", data);
|
||||
if (data.fatal) {
|
||||
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||
@@ -647,31 +672,37 @@
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsFatalRecoveryAttempts++;
|
||||
switch (fatalNetworkErrorAction({
|
||||
positionSeconds: currentTime,
|
||||
knownDurationSeconds: knownDuration,
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})) {
|
||||
case 'ended':
|
||||
log.debug('Fatal network error near end of stream - treating as ended');
|
||||
switch (
|
||||
fatalNetworkErrorAction({
|
||||
positionSeconds: currentTime,
|
||||
knownDurationSeconds: knownDuration,
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})
|
||||
) {
|
||||
case "ended":
|
||||
log.debug("Fatal network error near end of stream - treating as ended");
|
||||
notifyEnded();
|
||||
break;
|
||||
case 'retry':
|
||||
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
case "retry":
|
||||
log.error(
|
||||
"Fatal network error, trying to recover (attempt",
|
||||
hlsFatalRecoveryAttempts,
|
||||
")",
|
||||
);
|
||||
hls!.startLoad();
|
||||
break;
|
||||
case 'giveUp':
|
||||
log.error('Fatal network error, max recovery attempts reached');
|
||||
case "giveUp":
|
||||
log.error("Fatal network error, max recovery attempts reached");
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
log.error('Fatal media error, trying to recover');
|
||||
log.error("Fatal media error, trying to recover");
|
||||
hls!.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
log.error('Unrecoverable HLS error');
|
||||
log.error("Unrecoverable HLS error");
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
@@ -681,7 +712,7 @@
|
||||
|
||||
// Cleanup on effect re-run
|
||||
return () => {
|
||||
log.debug('Effect cleanup: destroying HLS instance');
|
||||
log.debug("Effect cleanup: destroying HLS instance");
|
||||
if (hls) {
|
||||
hls.detachMedia();
|
||||
hls.stopLoad();
|
||||
@@ -692,13 +723,13 @@
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS support (Safari)
|
||||
log.debug('Using native HLS support');
|
||||
log.debug("Using native HLS support");
|
||||
videoElement.src = currentStreamUrl;
|
||||
} else {
|
||||
// Not an HLS stream, use regular video element
|
||||
log.debug('Using regular video element for non-HLS stream');
|
||||
log.debug("Using regular video element for non-HLS stream");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -707,7 +738,12 @@
|
||||
if (videoElement) {
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||
log.debug(
|
||||
"Video element configured: muted=",
|
||||
videoElement.muted,
|
||||
"volume=",
|
||||
videoElement.volume,
|
||||
);
|
||||
|
||||
// DIAGNOSTIC: Check if video has audio tracks
|
||||
if ((videoElement as any).audioTracks) {
|
||||
@@ -715,7 +751,7 @@
|
||||
|
||||
// Set initial audio track (prefer default track)
|
||||
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
||||
const defaultTrack = audioTracks().find(t => t.isDefault);
|
||||
const defaultTrack = audioTracks().find((t) => t.isDefault);
|
||||
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
||||
log.debug("Selected default audio track:", selectedAudioTrackIndex);
|
||||
}
|
||||
@@ -724,7 +760,10 @@
|
||||
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||
}
|
||||
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
||||
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||
log.debug(
|
||||
"webkitAudioDecodedByteCount:",
|
||||
(videoElement as any).webkitAudioDecodedByteCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -768,10 +807,7 @@
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
onMount(() => {
|
||||
Promise.all([
|
||||
commands.playerGetStreamingQualities(),
|
||||
commands.playerGetVideoSettings(),
|
||||
])
|
||||
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
@@ -883,14 +919,18 @@
|
||||
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
||||
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
||||
try {
|
||||
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||
log.debug(
|
||||
"Using HTML5 for direct stream - stopping backend player to prevent dual audio",
|
||||
);
|
||||
await commands.playerStop();
|
||||
didStopBackendEarly = true; // Track that we stopped the backend
|
||||
} catch (err) {
|
||||
log.warn("Failed to stop backend player:", err);
|
||||
}
|
||||
} else if (useHtml5Element && needsTranscoding) {
|
||||
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
||||
log.debug(
|
||||
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
|
||||
);
|
||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
@@ -902,7 +942,9 @@
|
||||
{
|
||||
const host = createRustReportHost(media.id, {
|
||||
onEnded: () => notifyEnded(),
|
||||
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
||||
onStreamUrlChanged: (u) => {
|
||||
currentStreamUrl = u;
|
||||
},
|
||||
});
|
||||
playerAdapter = createAdapter({
|
||||
backendKind: useHtml5Element ? "html5" : "native",
|
||||
@@ -966,12 +1008,12 @@
|
||||
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
|
||||
currentTime = event.payload.position;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
nativeUnlisteners.push(
|
||||
await listen("player://state-changed", (event: any) => {
|
||||
isPlaying = event.payload.state === "playing";
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1053,7 +1095,7 @@
|
||||
` paused=${videoElement.paused}` +
|
||||
` seeking=${videoElement.seeking}` +
|
||||
` rate=${videoElement.playbackRate}` +
|
||||
` buffered=${bufferedRanges.join(", ")}`
|
||||
` buffered=${bufferedRanges.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
@@ -1124,7 +1166,7 @@
|
||||
// Stop video element playback
|
||||
if (videoElement) {
|
||||
videoElement.pause();
|
||||
videoElement.src = '';
|
||||
videoElement.src = "";
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
@@ -1199,7 +1241,12 @@
|
||||
log.debug("Needs transcoding:", needsTranscoding);
|
||||
|
||||
// For direct streams without runTimeTicks, use video element's duration
|
||||
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
||||
if (
|
||||
videoElement &&
|
||||
videoElement.duration &&
|
||||
!isNaN(videoElement.duration) &&
|
||||
videoElement.duration !== Infinity
|
||||
) {
|
||||
const newDuration = videoElement.duration;
|
||||
log.debug("Setting videoDuration to:", newDuration);
|
||||
videoDuration = newDuration;
|
||||
@@ -1262,8 +1309,17 @@
|
||||
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||
await doSeek();
|
||||
} else {
|
||||
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
||||
log.debug(
|
||||
"Deferring foreground seek until loadedmetadata:",
|
||||
(seekOffset + seekTo).toFixed(1),
|
||||
);
|
||||
el.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
void doSeek();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1368,7 +1424,13 @@
|
||||
log.error("Network state:", networkStates[video.networkState] || video.networkState);
|
||||
|
||||
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
||||
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
||||
const readyStates = [
|
||||
"HAVE_NOTHING",
|
||||
"HAVE_METADATA",
|
||||
"HAVE_CURRENT_DATA",
|
||||
"HAVE_FUTURE_DATA",
|
||||
"HAVE_ENOUGH_DATA",
|
||||
];
|
||||
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
|
||||
}
|
||||
|
||||
@@ -1399,10 +1461,16 @@
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement) {
|
||||
log.warn("canplay event did not fire within 5 seconds");
|
||||
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
||||
log.debug(
|
||||
"Fallback check - readyState:",
|
||||
videoElement.readyState,
|
||||
"networkState:",
|
||||
videoElement.networkState,
|
||||
);
|
||||
|
||||
// Check if video is actually ready despite event not firing
|
||||
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||
if (videoElement.readyState >= 3) {
|
||||
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
|
||||
markMediaReady();
|
||||
}
|
||||
@@ -1519,7 +1587,7 @@
|
||||
` ended=${el?.ended}` +
|
||||
` isSeeking=${isSeeking}` +
|
||||
` isBuffering=${isBuffering}` +
|
||||
` handoff=${handoffState.active}`
|
||||
` handoff=${handoffState.active}`,
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
@@ -1612,7 +1680,7 @@
|
||||
await playerController.seekVideo(
|
||||
targetTime,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex ?? null
|
||||
selectedAudioTrackIndex ?? null,
|
||||
);
|
||||
|
||||
// Resume smooth updates if still playing after the seek settled.
|
||||
@@ -1684,12 +1752,14 @@
|
||||
if (!media) return;
|
||||
// Ask the server for an audio-only stream of this video item (no video
|
||||
// decode), carrying the selected audio track and resume position.
|
||||
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
const audioUrl = await auth
|
||||
.getRepository()
|
||||
.getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
await commands.playerEnterBackgroundAudio(
|
||||
{
|
||||
id: media.id,
|
||||
@@ -2110,7 +2180,7 @@
|
||||
streamIndex,
|
||||
arrayIndex,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null
|
||||
mediaSourceId ?? null,
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
@@ -2125,7 +2195,7 @@
|
||||
if (!userId) return;
|
||||
|
||||
// Find the selected track info
|
||||
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
|
||||
const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
|
||||
if (selectedTrack) {
|
||||
await commands.storageSaveSeriesAudioPreference(
|
||||
userId,
|
||||
@@ -2133,9 +2203,12 @@
|
||||
media.serverId ?? "",
|
||||
selectedTrack.displayTitle || null,
|
||||
selectedTrack.language || null,
|
||||
streamIndex
|
||||
streamIndex,
|
||||
);
|
||||
log.debug(
|
||||
"Saved series audio preference:",
|
||||
selectedTrack.displayTitle || selectedTrack.language,
|
||||
);
|
||||
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to save series audio preference:", err);
|
||||
@@ -2175,7 +2248,7 @@
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex
|
||||
selectedAudioTrackIndex,
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
@@ -2245,7 +2318,12 @@
|
||||
try {
|
||||
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||
await commands.playerSetSubtitleTrack(indexToUse);
|
||||
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||
log.debug(
|
||||
"Native backend subtitle track changed - streamIndex:",
|
||||
streamIndex,
|
||||
"position:",
|
||||
indexToUse,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to set subtitle track:", error);
|
||||
}
|
||||
@@ -2269,7 +2347,7 @@
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col z-50"
|
||||
class:bg-black={useHtml5Element}
|
||||
style:background-color={!useHtml5Element ? 'transparent' : ''}
|
||||
style:background-color={!useHtml5Element ? "transparent" : ""}
|
||||
onmousemove={handleMouseMove}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
@@ -2282,44 +2360,44 @@
|
||||
{#if !!useHtml5Element}
|
||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
playsinline
|
||||
autoplay
|
||||
muted={false}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onloadedmetadata={handleLoadedMetadata}
|
||||
oncanplay={handleCanPlay}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onended={handleEnded}
|
||||
onerror={handleError}
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!--
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
playsinline
|
||||
autoplay
|
||||
muted={false}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onloadedmetadata={handleLoadedMetadata}
|
||||
oncanplay={handleCanPlay}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onended={handleEnded}
|
||||
onerror={handleError}
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!--
|
||||
Subtitles for the HTML5 path. `src` is a resolved string (see
|
||||
renderedSubtitleTracks); `data-stream-index` is what
|
||||
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
|
||||
attribute: a default track auto-shows, which would contradict the
|
||||
menu opening on "Off".
|
||||
-->
|
||||
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={track.url}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
data-stream-index={track.streamIndex}
|
||||
/>
|
||||
{/each}
|
||||
</video>
|
||||
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={track.url}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
data-stream-index={track.streamIndex}
|
||||
/>
|
||||
{/each}
|
||||
</video>
|
||||
{:else}
|
||||
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
||||
<!-- Leave this area transparent so video shows through -->
|
||||
@@ -2350,7 +2428,9 @@
|
||||
|
||||
<!-- Loading spinner overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2360,8 +2440,12 @@
|
||||
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" 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 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||
<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 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
|
||||
/>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
|
||||
>{SEEK_BACKWARD_SECONDS}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2371,8 +2455,12 @@
|
||||
<div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" 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 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||
<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 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
|
||||
/>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
|
||||
>+{SEEK_FORWARD_SECONDS}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2383,12 +2471,17 @@
|
||||
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" />
|
||||
<path
|
||||
d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white text-xs font-medium">Brightness</span>
|
||||
<div class="w-24 h-1 bg-white/30 rounded-full mt-1">
|
||||
<div class="h-full bg-white rounded-full" style="width: {((brightness - 0.3) / 1.4) * 100}%"></div>
|
||||
<div
|
||||
class="h-full bg-white rounded-full"
|
||||
style="width: {((brightness - 0.3) / 1.4) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2398,7 +2491,9 @@
|
||||
<!-- Loading overlay for seeking -->
|
||||
{#if isSeeking}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if !isPlaying}
|
||||
<!-- Play overlay. Visually this IS the video surface, so it is marked
|
||||
@@ -2454,7 +2549,9 @@
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 text-white/80 text-sm">
|
||||
<!-- No resolved Jellyfin id → no headshot available. -->
|
||||
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
|
||||
<span
|
||||
class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400"
|
||||
>
|
||||
{actor.name.slice(0, 1)}
|
||||
</span>
|
||||
<span>{actor.name}</span>
|
||||
@@ -2506,9 +2603,9 @@
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarRelease}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmousedown={() => (isDraggingSeekBar = true)}
|
||||
onmouseup={handleSeekBarRelease}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchstart={() => (isDraggingSeekBar = true)}
|
||||
ontouchend={handleSeekBarRelease}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
@@ -2522,7 +2619,11 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button onclick={togglePlayPause} class="text-white hover:text-gray-300" aria-label={isPlaying ? "Pause" : "Play"}>
|
||||
<button
|
||||
onclick={togglePlayPause}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
@@ -2554,13 +2655,17 @@
|
||||
aria-label="Select audio track"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Audio Track Menu -->
|
||||
{#if showAudioTrackMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Audio Track
|
||||
@@ -2568,7 +2673,10 @@
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex === track.index ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
@@ -2577,8 +2685,12 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2600,12 +2712,16 @@
|
||||
>
|
||||
<!-- Speedometer: bitrate ceiling -->
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/>
|
||||
<path
|
||||
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showQualityMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
@@ -2613,15 +2729,22 @@
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2641,13 +2764,17 @@
|
||||
aria-label="Select subtitles"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"/>
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Subtitle Menu -->
|
||||
{#if showSubtitleMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
@@ -2655,12 +2782,19 @@
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === null ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2668,7 +2802,10 @@
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
@@ -2685,8 +2822,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2699,15 +2840,23 @@
|
||||
|
||||
<!-- Sleep Timer -->
|
||||
{#if $sleepTimerActive}
|
||||
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
|
||||
<SleepTimerIndicator
|
||||
onClick={() => {
|
||||
showSleepTimerModal = true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { showSleepTimerModal = true; }}
|
||||
onclick={() => {
|
||||
showSleepTimerModal = true;
|
||||
}}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" 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" />
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -2723,7 +2872,9 @@
|
||||
aria-label="Picture in picture"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
|
||||
<path
|
||||
d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -2733,23 +2884,35 @@
|
||||
{#if backgroundAudioSupported}
|
||||
<button
|
||||
onclick={toggleBackgroundAudio}
|
||||
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
|
||||
class={backgroundAudioOn
|
||||
? "text-blue-400 hover:text-blue-300"
|
||||
: "text-white hover:text-gray-300"}
|
||||
aria-label="Background audio"
|
||||
aria-pressed={backgroundAudioOn}
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
|
||||
<path
|
||||
d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen -->
|
||||
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Toggle fullscreen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
{#if isFullscreen}
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
<path
|
||||
d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
|
||||
/>
|
||||
{:else}
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
<path
|
||||
d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
@@ -2757,7 +2920,12 @@
|
||||
<!-- Close -->
|
||||
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2765,7 +2933,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
|
||||
<SleepTimerModal
|
||||
isOpen={showSleepTimerModal}
|
||||
onClose={() => {
|
||||
showSleepTimerModal = false;
|
||||
}}
|
||||
mediaType={media?.type}
|
||||
/>
|
||||
|
||||
<style>
|
||||
@keyframes fade-out {
|
||||
|
||||
@@ -116,7 +116,7 @@ function touchAt(el: Element, x: number) {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [touch] as unknown as Touch[],
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,9 +139,7 @@ async function mountAndroidPlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
@@ -157,11 +155,7 @@ function touch(x: number, y: number) {
|
||||
* A real drag along the bar moves the finger far enough that the container's
|
||||
* swipe detector (50px) would trigger if it were still listening.
|
||||
*/
|
||||
async function touchScrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||
// drag; the horizontal travel is what matters.
|
||||
@@ -187,7 +181,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
@@ -216,7 +210,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
await tick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
{#if showSlider}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showSlider = false}
|
||||
onclick={() => (showSlider = false)}
|
||||
aria-label="Close volume"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const initialHandoffState: BackgroundAudioState = {
|
||||
*/
|
||||
export function shouldEnterBackgroundAudio(
|
||||
toggleOn: boolean,
|
||||
state: BackgroundAudioState
|
||||
state: BackgroundAudioState,
|
||||
): boolean {
|
||||
return toggleOn && !state.active;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean
|
||||
*/
|
||||
export function shouldResumeOnForeground(
|
||||
wasPlaying: boolean,
|
||||
nativeStateKind: string | undefined
|
||||
nativeStateKind: string | undefined,
|
||||
): boolean {
|
||||
return wasPlaying && nativeStateKind !== "paused";
|
||||
}
|
||||
|
||||
@@ -37,10 +37,7 @@ export interface FatalNetworkErrorInput {
|
||||
}
|
||||
|
||||
/** Whether a failure at this position should be read as the stream ending. */
|
||||
export function isNearEndOfStream(
|
||||
positionSeconds: number,
|
||||
knownDurationSeconds: number
|
||||
): boolean {
|
||||
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
|
||||
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
|
||||
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
|
||||
}
|
||||
|
||||
@@ -16,34 +16,26 @@ describe("nativeSignalRevealsVideo", () => {
|
||||
"leaves the poster up on state %s",
|
||||
(state) => {
|
||||
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reveals on a position tick that carries a duration", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
|
||||
).toBe(true);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals on a position tick that has advanced, even with no duration", () => {
|
||||
// Live streams report no duration; an advancing position is still proof
|
||||
// that the surface has content.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
|
||||
).toBe(true);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the poster up on an empty position tick", () => {
|
||||
// A tick before anything is loaded proves nothing, and revealing here would
|
||||
// show a transparent hole through the app.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
|
||||
).toBe(false);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a negative position as progress", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
|
||||
).toBe(false);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
/** A player event that might mean "the surface has a picture on it". */
|
||||
export type NativeRevealSignal =
|
||||
| { kind: "state"; state: string }
|
||||
| { kind: "position"; position: number; duration: number };
|
||||
{ kind: "state"; state: string } | { kind: "position"; position: number; duration: number };
|
||||
|
||||
/**
|
||||
* Whether `signal` proves the native backend is rendering, and the poster card
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "episode-1",
|
||||
isVideo: true,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: null,
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
isVideo: false,
|
||||
startPosition: 42,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "episode-2",
|
||||
isVideo: true,
|
||||
forceRestart: true,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
describe("resolvePlayerSurface", () => {
|
||||
it("renders the video surface for video with a stream URL", () => {
|
||||
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
|
||||
"video"
|
||||
"video",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,8 +68,18 @@ describe("subtitleStreamsOf", () => {
|
||||
*/
|
||||
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
|
||||
const streams: SubtitleStreamLike[] = [
|
||||
{ index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
|
||||
{ index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
|
||||
{
|
||||
index: 2,
|
||||
kind: "subtitle",
|
||||
displayTitle: "English PGS SDH",
|
||||
supportsExternalDelivery: false,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
kind: "subtitle",
|
||||
displayTitle: "English Text SDH",
|
||||
supportsExternalDelivery: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
|
||||
@@ -119,7 +129,9 @@ describe("subtitleStreamsOf", () => {
|
||||
|
||||
describe("subtitleTrackLabel", () => {
|
||||
it("prefers the display title, then language, then the index", () => {
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe(
|
||||
"English (SRT)",
|
||||
);
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
||||
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
||||
});
|
||||
@@ -309,10 +321,7 @@ describe("nativeSubtitleArrayIndex", () => {
|
||||
});
|
||||
|
||||
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, "VideoPlayer.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||
|
||||
it("renders <track> elements instead of leaving them commented out", () => {
|
||||
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||
|
||||
@@ -209,9 +209,7 @@ export function videoCrossOriginMode(
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||
*/
|
||||
export function nativeSubtitleTracks(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
): SubtitleTrack[] {
|
||||
export function nativeSubtitleTracks(tracks: readonly RenderableSubtitleTrack[]): SubtitleTrack[] {
|
||||
return tracks.map((track) => ({
|
||||
index: track.streamIndex,
|
||||
url: track.url,
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("seek target resolution", () => {
|
||||
// that starts at/after the media end, which the server never produces —
|
||||
// the fetch times out and the gap-controller stalls in a pause loop.
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
|
||||
DURATION - END_SEEK_MARGIN_SECONDS
|
||||
DURATION - END_SEEK_MARGIN_SECONDS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -214,9 +214,9 @@ describe("control-surface touches are not gestures", () => {
|
||||
});
|
||||
|
||||
it("treats anything inside the controls bar as a control", () => {
|
||||
expect(
|
||||
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
|
||||
).toBe(true);
|
||||
expect(isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a tap on the bare video surface through as a gesture", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const TOUCH_CLICK_SUPPRESS_MS = 700;
|
||||
* testable without a DOM.
|
||||
*/
|
||||
export function isControlSurfaceTouch(
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>,
|
||||
): boolean {
|
||||
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
|
||||
for (const node of ancestors) {
|
||||
@@ -75,7 +75,7 @@ export function isControlSurfaceTouch(
|
||||
export function isSynthesizedTouchClick(
|
||||
detail: number,
|
||||
now: number,
|
||||
lastTouchTapAt: number
|
||||
lastTouchTapAt: number,
|
||||
): boolean {
|
||||
if (detail === 0) return true;
|
||||
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
|
||||
@@ -219,7 +219,9 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||
|
||||
const base =
|
||||
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||
pendingTarget != null &&
|
||||
Math.abs(pendingTarget - reportedPosition) > 0.5 &&
|
||||
pendingTarget > reportedPosition
|
||||
? pendingTarget
|
||||
: reportedPosition;
|
||||
|
||||
|
||||
@@ -37,10 +37,7 @@ export function fittedVideoSize(
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
containerWidth / intrinsicWidth,
|
||||
containerHeight / intrinsicHeight,
|
||||
);
|
||||
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
|
||||
|
||||
return {
|
||||
width: intrinsicWidth * scale,
|
||||
|
||||
Reference in New Issue
Block a user