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:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
+161 -143
View File
@@ -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}
+12 -4
View File
@@ -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">
+57 -29
View File
@@ -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>
+36 -16
View File
@@ -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);
});
+326 -152
View File
@@ -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";
}
+1 -4
View File
@@ -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;
}
+5 -13
View File
@@ -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);
});
});
+1 -2
View File
@@ -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");
+1 -3
View File
@@ -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", () => {
+5 -3
View File
@@ -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;
+1 -4
View File
@@ -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,