@livepeer-frameworks/player-svelte 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DevModePanel.svelte +650 -0
- package/dist/DevModePanel.svelte.d.ts +31 -0
- package/dist/DvdLogo.svelte +213 -0
- package/dist/DvdLogo.svelte.d.ts +7 -0
- package/dist/Icons.svelte +27 -0
- package/dist/Icons.svelte.d.ts +25 -0
- package/dist/IdleScreen.svelte +752 -0
- package/dist/IdleScreen.svelte.d.ts +11 -0
- package/dist/LoadingScreen.svelte +689 -0
- package/dist/LoadingScreen.svelte.d.ts +7 -0
- package/dist/Player.svelte +482 -0
- package/dist/Player.svelte.d.ts +26 -0
- package/dist/PlayerControls.svelte +739 -0
- package/dist/PlayerControls.svelte.d.ts +20 -0
- package/dist/SeekBar.svelte +274 -0
- package/dist/SeekBar.svelte.d.ts +25 -0
- package/dist/SkipIndicator.svelte +95 -0
- package/dist/SkipIndicator.svelte.d.ts +14 -0
- package/dist/SpeedIndicator.svelte +38 -0
- package/dist/SpeedIndicator.svelte.d.ts +8 -0
- package/dist/StatsPanel.svelte +155 -0
- package/dist/StatsPanel.svelte.d.ts +27 -0
- package/dist/StreamStateOverlay.svelte +266 -0
- package/dist/StreamStateOverlay.svelte.d.ts +18 -0
- package/dist/SubtitleRenderer.svelte +234 -0
- package/dist/SubtitleRenderer.svelte.d.ts +41 -0
- package/dist/ThumbnailOverlay.svelte +96 -0
- package/dist/ThumbnailOverlay.svelte.d.ts +11 -0
- package/dist/TitleOverlay.svelte +47 -0
- package/dist/TitleOverlay.svelte.d.ts +9 -0
- package/dist/assets/logomark.svg +56 -0
- package/dist/components/VolumeIcons.svelte +53 -0
- package/dist/components/VolumeIcons.svelte.d.ts +10 -0
- package/dist/global.d.ts +15 -0
- package/dist/icons/FullscreenExitIcon.svelte +33 -0
- package/dist/icons/FullscreenExitIcon.svelte.d.ts +8 -0
- package/dist/icons/FullscreenIcon.svelte +33 -0
- package/dist/icons/FullscreenIcon.svelte.d.ts +8 -0
- package/dist/icons/PauseIcon.svelte +28 -0
- package/dist/icons/PauseIcon.svelte.d.ts +8 -0
- package/dist/icons/PictureInPictureIcon.svelte +28 -0
- package/dist/icons/PictureInPictureIcon.svelte.d.ts +8 -0
- package/dist/icons/PlayIcon.svelte +27 -0
- package/dist/icons/PlayIcon.svelte.d.ts +8 -0
- package/dist/icons/SeekToLiveIcon.svelte +30 -0
- package/dist/icons/SeekToLiveIcon.svelte.d.ts +8 -0
- package/dist/icons/SettingsIcon.svelte +40 -0
- package/dist/icons/SettingsIcon.svelte.d.ts +8 -0
- package/dist/icons/SkipBackIcon.svelte +32 -0
- package/dist/icons/SkipBackIcon.svelte.d.ts +8 -0
- package/dist/icons/SkipForwardIcon.svelte +32 -0
- package/dist/icons/SkipForwardIcon.svelte.d.ts +8 -0
- package/dist/icons/StatsIcon.svelte +29 -0
- package/dist/icons/StatsIcon.svelte.d.ts +8 -0
- package/dist/icons/VolumeOffIcon.svelte +29 -0
- package/dist/icons/VolumeOffIcon.svelte.d.ts +8 -0
- package/dist/icons/VolumeUpIcon.svelte +34 -0
- package/dist/icons/VolumeUpIcon.svelte.d.ts +8 -0
- package/dist/icons/index.d.ts +17 -0
- package/dist/icons/index.js +17 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +54 -0
- package/dist/player.css +2 -0
- package/dist/stores/index.d.ts +15 -0
- package/dist/stores/index.js +21 -0
- package/dist/stores/playbackQuality.d.ts +43 -0
- package/dist/stores/playbackQuality.js +107 -0
- package/dist/stores/playerContext.d.ts +73 -0
- package/dist/stores/playerContext.js +166 -0
- package/dist/stores/playerController.d.ts +178 -0
- package/dist/stores/playerController.js +358 -0
- package/dist/stores/playerSelection.d.ts +84 -0
- package/dist/stores/playerSelection.js +159 -0
- package/dist/stores/streamState.d.ts +44 -0
- package/dist/stores/streamState.js +314 -0
- package/dist/stores/viewerEndpoints.d.ts +48 -0
- package/dist/stores/viewerEndpoints.js +178 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +4 -0
- package/dist/ui/Badge.svelte +21 -0
- package/dist/ui/Badge.svelte.d.ts +32 -0
- package/dist/ui/Button.svelte +42 -0
- package/dist/ui/Button.svelte.d.ts +35 -0
- package/dist/ui/Slider.svelte +100 -0
- package/dist/ui/Slider.svelte.d.ts +17 -0
- package/dist/ui/badge.d.ts +6 -0
- package/dist/ui/badge.js +10 -0
- package/dist/ui/button.d.ts +8 -0
- package/dist/ui/button.js +21 -0
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +31 -0
- package/dist/ui/context-menu/ContextMenuContent.svelte +17 -0
- package/dist/ui/context-menu/ContextMenuContent.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuItem.svelte +22 -0
- package/dist/ui/context-menu/ContextMenuItem.svelte.d.ts +8 -0
- package/dist/ui/context-menu/ContextMenuLabel.svelte +22 -0
- package/dist/ui/context-menu/ContextMenuLabel.svelte.d.ts +8 -0
- package/dist/ui/context-menu/ContextMenuPortal.svelte +11 -0
- package/dist/ui/context-menu/ContextMenuPortal.svelte.d.ts +6 -0
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +31 -0
- package/dist/ui/context-menu/ContextMenuSeparator.svelte +14 -0
- package/dist/ui/context-menu/ContextMenuSeparator.svelte.d.ts +6 -0
- package/dist/ui/context-menu/ContextMenuShortcut.svelte +19 -0
- package/dist/ui/context-menu/ContextMenuShortcut.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuSubContent.svelte +20 -0
- package/dist/ui/context-menu/ContextMenuSubContent.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
- package/dist/ui/context-menu/ContextMenuSubTrigger.svelte.d.ts +8 -0
- package/dist/ui/context-menu/index.d.ts +17 -0
- package/dist/ui/context-menu/index.js +17 -0
- package/package.json +51 -0
- package/src/DevModePanel.svelte +650 -0
- package/src/DvdLogo.svelte +213 -0
- package/src/Icons.svelte +27 -0
- package/src/IdleScreen.svelte +739 -0
- package/src/LoadingScreen.svelte +674 -0
- package/src/Player.svelte +483 -0
- package/src/PlayerControls.svelte +752 -0
- package/src/SeekBar.svelte +274 -0
- package/src/SkipIndicator.svelte +95 -0
- package/src/SpeedIndicator.svelte +37 -0
- package/src/StatsPanel.svelte +155 -0
- package/src/StreamStateOverlay.svelte +266 -0
- package/src/SubtitleRenderer.svelte +234 -0
- package/src/ThumbnailOverlay.svelte +96 -0
- package/src/TitleOverlay.svelte +47 -0
- package/src/assets/logomark.svg +56 -0
- package/src/components/VolumeIcons.svelte +53 -0
- package/src/global.d.ts +15 -0
- package/src/icons/FullscreenExitIcon.svelte +33 -0
- package/src/icons/FullscreenIcon.svelte +33 -0
- package/src/icons/PauseIcon.svelte +28 -0
- package/src/icons/PictureInPictureIcon.svelte +28 -0
- package/src/icons/PlayIcon.svelte +27 -0
- package/src/icons/SeekToLiveIcon.svelte +30 -0
- package/src/icons/SettingsIcon.svelte +40 -0
- package/src/icons/SkipBackIcon.svelte +32 -0
- package/src/icons/SkipForwardIcon.svelte +32 -0
- package/src/icons/StatsIcon.svelte +29 -0
- package/src/icons/VolumeOffIcon.svelte +29 -0
- package/src/icons/VolumeUpIcon.svelte +34 -0
- package/src/icons/index.ts +18 -0
- package/src/index.ts +84 -0
- package/src/player.css +2 -0
- package/src/stores/index.ts +88 -0
- package/src/stores/playbackQuality.ts +137 -0
- package/src/stores/playerContext.ts +221 -0
- package/src/stores/playerController.ts +568 -0
- package/src/stores/playerSelection.ts +216 -0
- package/src/stores/streamState.ts +367 -0
- package/src/stores/viewerEndpoints.ts +224 -0
- package/src/types.ts +6 -0
- package/src/ui/Badge.svelte +21 -0
- package/src/ui/Button.svelte +42 -0
- package/src/ui/Slider.svelte +100 -0
- package/src/ui/badge.ts +20 -0
- package/src/ui/button.ts +35 -0
- package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
- package/src/ui/context-menu/ContextMenuContent.svelte +17 -0
- package/src/ui/context-menu/ContextMenuItem.svelte +22 -0
- package/src/ui/context-menu/ContextMenuLabel.svelte +22 -0
- package/src/ui/context-menu/ContextMenuPortal.svelte +11 -0
- package/src/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
- package/src/ui/context-menu/ContextMenuSeparator.svelte +14 -0
- package/src/ui/context-menu/ContextMenuShortcut.svelte +19 -0
- package/src/ui/context-menu/ContextMenuSubContent.svelte +20 -0
- package/src/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
- package/src/ui/context-menu/index.ts +36 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
cn,
|
|
4
|
+
globalPlayerManager,
|
|
5
|
+
type MistStreamInfo,
|
|
6
|
+
type PlaybackMode,
|
|
7
|
+
// Seeking utilities from core
|
|
8
|
+
SPEED_PRESETS,
|
|
9
|
+
getLatencyTier,
|
|
10
|
+
isMediaStreamSource,
|
|
11
|
+
supportsPlaybackRate as coreSupportsPlaybackRate,
|
|
12
|
+
calculateSeekableRange,
|
|
13
|
+
canSeekStream,
|
|
14
|
+
calculateLiveThresholds,
|
|
15
|
+
calculateIsNearLive,
|
|
16
|
+
isLiveContent,
|
|
17
|
+
// Time formatting from core
|
|
18
|
+
formatTime,
|
|
19
|
+
formatTimeDisplay,
|
|
20
|
+
} from '@livepeer-frameworks/player-core';
|
|
21
|
+
import SeekBar from './SeekBar.svelte';
|
|
22
|
+
import Slider from './ui/Slider.svelte';
|
|
23
|
+
import VolumeIcons from './components/VolumeIcons.svelte';
|
|
24
|
+
import {
|
|
25
|
+
StatsIcon,
|
|
26
|
+
SettingsIcon,
|
|
27
|
+
PlayIcon,
|
|
28
|
+
PauseIcon,
|
|
29
|
+
SkipBackIcon,
|
|
30
|
+
SkipForwardIcon,
|
|
31
|
+
FullscreenIcon,
|
|
32
|
+
FullscreenExitIcon,
|
|
33
|
+
SeekToLiveIcon,
|
|
34
|
+
} from './icons';
|
|
35
|
+
|
|
36
|
+
// Props - aligned with React PlayerControls
|
|
37
|
+
interface Props {
|
|
38
|
+
currentTime: number;
|
|
39
|
+
duration: number;
|
|
40
|
+
isVisible?: boolean;
|
|
41
|
+
onseek?: (time: number) => void;
|
|
42
|
+
mistStreamInfo?: MistStreamInfo;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
playbackMode?: PlaybackMode;
|
|
45
|
+
onModeChange?: (mode: PlaybackMode) => void;
|
|
46
|
+
sourceType?: string;
|
|
47
|
+
showStatsButton?: boolean;
|
|
48
|
+
isStatsOpen?: boolean;
|
|
49
|
+
onStatsToggle?: () => void;
|
|
50
|
+
/** Content-type based live flag (for mode selector visibility, separate from seek bar isLive) */
|
|
51
|
+
isContentLive?: boolean;
|
|
52
|
+
/** Jump to live edge callback */
|
|
53
|
+
onJumpToLive?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
currentTime,
|
|
58
|
+
duration,
|
|
59
|
+
isVisible = true,
|
|
60
|
+
onseek = undefined,
|
|
61
|
+
mistStreamInfo = undefined,
|
|
62
|
+
disabled = false,
|
|
63
|
+
playbackMode = 'auto',
|
|
64
|
+
onModeChange = undefined,
|
|
65
|
+
sourceType = undefined,
|
|
66
|
+
showStatsButton = false,
|
|
67
|
+
isStatsOpen = false,
|
|
68
|
+
onStatsToggle = undefined,
|
|
69
|
+
isContentLive = undefined,
|
|
70
|
+
onJumpToLive = undefined,
|
|
71
|
+
}: Props = $props();
|
|
72
|
+
|
|
73
|
+
// Video element discovery
|
|
74
|
+
let video: HTMLVideoElement | null = $state(null);
|
|
75
|
+
let videoCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
76
|
+
|
|
77
|
+
function findVideoElement(): HTMLVideoElement | null {
|
|
78
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
79
|
+
if (player?.getVideoElement?.()) return player.getVideoElement();
|
|
80
|
+
return document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null
|
|
81
|
+
?? document.querySelector('.fw-player-container video') as HTMLVideoElement | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
video = findVideoElement();
|
|
86
|
+
if (!video) {
|
|
87
|
+
videoCheckInterval = setInterval(() => {
|
|
88
|
+
const v = findVideoElement();
|
|
89
|
+
if (v) {
|
|
90
|
+
video = v;
|
|
91
|
+
if (videoCheckInterval) {
|
|
92
|
+
clearInterval(videoCheckInterval);
|
|
93
|
+
videoCheckInterval = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, 100);
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
if (videoCheckInterval) {
|
|
99
|
+
clearInterval(videoCheckInterval);
|
|
100
|
+
videoCheckInterval = null;
|
|
101
|
+
}
|
|
102
|
+
}, 5000);
|
|
103
|
+
}
|
|
104
|
+
return () => {
|
|
105
|
+
if (videoCheckInterval) {
|
|
106
|
+
clearInterval(videoCheckInterval);
|
|
107
|
+
videoCheckInterval = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Local state
|
|
113
|
+
let isPlaying = $state(false);
|
|
114
|
+
let isMuted = $state(false);
|
|
115
|
+
let isFullscreen = $state(false);
|
|
116
|
+
let hasAudio = $state(true);
|
|
117
|
+
let volumeValue = $state(100);
|
|
118
|
+
let playbackRate = $state(1);
|
|
119
|
+
let showSettingsMenu = $state(false);
|
|
120
|
+
let isNearLiveState = $state(true);
|
|
121
|
+
let buffered: TimeRanges | undefined = $state(undefined);
|
|
122
|
+
let hasSeekToLive = false; // Track if we've auto-seeked to live
|
|
123
|
+
let qualityValue = $state('auto');
|
|
124
|
+
let captionValue = $state('none');
|
|
125
|
+
|
|
126
|
+
// Text tracks from player
|
|
127
|
+
let textTracks = $derived.by(() => {
|
|
128
|
+
return globalPlayerManager.getCurrentPlayer()?.getTextTracks?.() ?? [];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Quality selection priority:
|
|
132
|
+
// 1. Player-provided qualities (HLS.js/DASH.js levels with correct numeric indices)
|
|
133
|
+
// 2. Mist track metadata (for players that don't provide quality API)
|
|
134
|
+
// This fixes a critical bug where Mist track IDs (e.g., "a1", "v0") were passed to
|
|
135
|
+
// HLS/DASH players which expect numeric indices (e.g., "0", "1", "2")
|
|
136
|
+
let qualities = $derived.by(() => {
|
|
137
|
+
// Try player's quality API first - this returns properly indexed levels
|
|
138
|
+
const playerQualities = globalPlayerManager.getCurrentPlayer()?.getQualities?.();
|
|
139
|
+
if (playerQualities && playerQualities.length > 0) {
|
|
140
|
+
return playerQualities;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fallback to Mist track metadata for players without quality API
|
|
144
|
+
const mistTracks = mistStreamInfo?.meta?.tracks;
|
|
145
|
+
if (mistTracks) {
|
|
146
|
+
return Object.entries(mistTracks)
|
|
147
|
+
.filter(([, t]) => t.type === 'video')
|
|
148
|
+
.map(([id, t]) => ({
|
|
149
|
+
id,
|
|
150
|
+
label: t.height ? `${t.height}p` : t.codec,
|
|
151
|
+
width: t.width,
|
|
152
|
+
height: t.height,
|
|
153
|
+
bitrate: t.bps,
|
|
154
|
+
}))
|
|
155
|
+
.sort((a, b) => (b.height || 0) - (a.height || 0));
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Hover state for volume
|
|
161
|
+
let isVolumeHovered = $state(false);
|
|
162
|
+
let isVolumeFocused = $state(false);
|
|
163
|
+
let isVolumeExpanded = $derived(isVolumeHovered || isVolumeFocused);
|
|
164
|
+
|
|
165
|
+
// Derived values - using centralized core utilities
|
|
166
|
+
let isLive = $derived(isLiveContent(isContentLive, mistStreamInfo, duration));
|
|
167
|
+
let isWebRTC = $derived(isMediaStreamSource(video));
|
|
168
|
+
let supportsPlaybackRate = $derived(coreSupportsPlaybackRate(video));
|
|
169
|
+
function deriveBufferWindowMs(tracks?: Record<string, { firstms?: number; lastms?: number }>): number | undefined {
|
|
170
|
+
if (!tracks) return undefined;
|
|
171
|
+
const list = Object.values(tracks);
|
|
172
|
+
if (list.length === 0) return undefined;
|
|
173
|
+
const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
|
|
174
|
+
const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
|
|
175
|
+
if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
|
|
176
|
+
const firstms = Math.max(...firstmsValues);
|
|
177
|
+
const lastms = Math.min(...lastmsValues);
|
|
178
|
+
const window = lastms - firstms;
|
|
179
|
+
if (!Number.isFinite(window) || window <= 0) return undefined;
|
|
180
|
+
return window;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let bufferWindowMs = $derived(
|
|
184
|
+
mistStreamInfo?.meta?.buffer_window
|
|
185
|
+
?? deriveBufferWindowMs(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
function getPlayerSeekableRange(): { seekableStart: number; liveEdge: number } | null {
|
|
189
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
190
|
+
if (player && typeof (player as any).getSeekableRange === 'function') {
|
|
191
|
+
const range = (player as any).getSeekableRange();
|
|
192
|
+
if (range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start) {
|
|
193
|
+
return { seekableStart: range.start, liveEdge: range.end };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const allowMediaStreamDvr = isMediaStreamSource(video)
|
|
200
|
+
&& (bufferWindowMs !== undefined && bufferWindowMs > 0)
|
|
201
|
+
&& (sourceType !== 'whep' && sourceType !== 'webrtc');
|
|
202
|
+
|
|
203
|
+
// Seekable range using core calculation (allow player override)
|
|
204
|
+
let seekableRange = $derived.by(() => getPlayerSeekableRange() ?? calculateSeekableRange({
|
|
205
|
+
isLive,
|
|
206
|
+
video,
|
|
207
|
+
mistStreamInfo,
|
|
208
|
+
currentTime,
|
|
209
|
+
duration,
|
|
210
|
+
allowMediaStreamDvr,
|
|
211
|
+
}));
|
|
212
|
+
let seekableStart = $derived(seekableRange.seekableStart);
|
|
213
|
+
let liveEdge = $derived(seekableRange.liveEdge);
|
|
214
|
+
let hasDvrWindow = $derived(isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart);
|
|
215
|
+
let commitOnRelease = $derived(isLive);
|
|
216
|
+
|
|
217
|
+
// Live thresholds with buffer window scaling
|
|
218
|
+
let liveThresholds = $derived(calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs));
|
|
219
|
+
|
|
220
|
+
// Can seek - check player's canSeek method first (for WebCodecs, MEWS server-side seeking)
|
|
221
|
+
let baseCanSeek = $derived.by(() => {
|
|
222
|
+
// Check if current player has canSeek method
|
|
223
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
224
|
+
if (player && typeof (player as any).canSeek === 'function') {
|
|
225
|
+
return (player as any).canSeek();
|
|
226
|
+
}
|
|
227
|
+
// Fallback to core utility logic
|
|
228
|
+
return canSeekStream({
|
|
229
|
+
video,
|
|
230
|
+
isLive,
|
|
231
|
+
duration,
|
|
232
|
+
bufferWindowMs,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
let canSeek = $derived(baseCanSeek && (!isLive || hasDvrWindow));
|
|
236
|
+
|
|
237
|
+
// Update state from video events
|
|
238
|
+
$effect(() => {
|
|
239
|
+
if (!video) return;
|
|
240
|
+
|
|
241
|
+
function updatePlayingState() {
|
|
242
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
243
|
+
const paused = player?.isPaused?.() ?? video!.paused;
|
|
244
|
+
isPlaying = !paused;
|
|
245
|
+
}
|
|
246
|
+
function updateMutedState() {
|
|
247
|
+
isMuted = video!.muted || video!.volume === 0;
|
|
248
|
+
const vol = video!.volume;
|
|
249
|
+
volumeValue = Number.isFinite(vol) ? Math.round(vol * 100) : 100;
|
|
250
|
+
}
|
|
251
|
+
function updateFullscreenState() {
|
|
252
|
+
isFullscreen = !!document.fullscreenElement;
|
|
253
|
+
}
|
|
254
|
+
function updatePlaybackRate() { playbackRate = video!.playbackRate; }
|
|
255
|
+
function updateBuffered() {
|
|
256
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
257
|
+
buffered = player?.getBufferedRanges?.() ?? video!.buffered;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
updatePlayingState();
|
|
261
|
+
updateMutedState();
|
|
262
|
+
updateFullscreenState();
|
|
263
|
+
updatePlaybackRate();
|
|
264
|
+
updateBuffered();
|
|
265
|
+
|
|
266
|
+
video.addEventListener('play', updatePlayingState);
|
|
267
|
+
video.addEventListener('pause', updatePlayingState);
|
|
268
|
+
video.addEventListener('playing', updatePlayingState);
|
|
269
|
+
video.addEventListener('volumechange', updateMutedState);
|
|
270
|
+
video.addEventListener('ratechange', updatePlaybackRate);
|
|
271
|
+
video.addEventListener('progress', updateBuffered);
|
|
272
|
+
video.addEventListener('loadeddata', updateBuffered);
|
|
273
|
+
document.addEventListener('fullscreenchange', updateFullscreenState);
|
|
274
|
+
|
|
275
|
+
return () => {
|
|
276
|
+
video!.removeEventListener('play', updatePlayingState);
|
|
277
|
+
video!.removeEventListener('pause', updatePlayingState);
|
|
278
|
+
video!.removeEventListener('playing', updatePlayingState);
|
|
279
|
+
video!.removeEventListener('volumechange', updateMutedState);
|
|
280
|
+
video!.removeEventListener('ratechange', updatePlaybackRate);
|
|
281
|
+
video!.removeEventListener('progress', updateBuffered);
|
|
282
|
+
video!.removeEventListener('loadeddata', updateBuffered);
|
|
283
|
+
document.removeEventListener('fullscreenchange', updateFullscreenState);
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Reset seek-to-live flag when video element changes
|
|
288
|
+
$effect(() => {
|
|
289
|
+
if (video) {
|
|
290
|
+
hasSeekToLive = false;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Hysteresis for live badge - using core calculation
|
|
295
|
+
$effect(() => {
|
|
296
|
+
if (!isLive) {
|
|
297
|
+
isNearLiveState = true; // Always "at live" for VOD
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
isNearLiveState = calculateIsNearLive(currentTime, liveEdge, liveThresholds, isNearLiveState);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Time display - using core formatTimeDisplay
|
|
304
|
+
let timeDisplay = $derived(formatTimeDisplay({
|
|
305
|
+
isLive,
|
|
306
|
+
currentTime,
|
|
307
|
+
duration,
|
|
308
|
+
liveEdge,
|
|
309
|
+
seekableStart,
|
|
310
|
+
unixoffset: mistStreamInfo?.unixoffset,
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
// Seek value for slider
|
|
314
|
+
let seekValue = $derived.by(() => {
|
|
315
|
+
if (isLive) {
|
|
316
|
+
const window = liveEdge - seekableStart;
|
|
317
|
+
if (window <= 0) return 1000;
|
|
318
|
+
return ((currentTime - seekableStart) / window) * 1000;
|
|
319
|
+
}
|
|
320
|
+
return Number.isFinite(duration) && duration > 0 ? (currentTime / duration) * 1000 : 0;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Handlers
|
|
324
|
+
function handlePlayPause() {
|
|
325
|
+
if (disabled) return;
|
|
326
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
327
|
+
const v = player?.getVideoElement?.() ?? video;
|
|
328
|
+
if (!v && !player) return;
|
|
329
|
+
const paused = player?.isPaused?.() ?? v?.paused ?? true;
|
|
330
|
+
if (paused) {
|
|
331
|
+
player?.play?.().catch(() => {});
|
|
332
|
+
v?.play?.().catch(() => {});
|
|
333
|
+
} else {
|
|
334
|
+
player?.pause?.();
|
|
335
|
+
v?.pause?.();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleSkipBack() {
|
|
340
|
+
const newTime = Math.max(0, currentTime - 10);
|
|
341
|
+
if (onseek) {
|
|
342
|
+
onseek(newTime);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const v = findVideoElement();
|
|
346
|
+
if (v) v.currentTime = newTime;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function handleSkipForward() {
|
|
350
|
+
const maxTime = Number.isFinite(duration) ? duration : currentTime + 10;
|
|
351
|
+
const newTime = Math.min(maxTime, currentTime + 10);
|
|
352
|
+
if (onseek) {
|
|
353
|
+
onseek(newTime);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const v = findVideoElement();
|
|
357
|
+
if (v) v.currentTime = newTime;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function handleMute() {
|
|
361
|
+
if (disabled) return;
|
|
362
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
363
|
+
const v = player?.getVideoElement?.() ?? video;
|
|
364
|
+
if (!v) return;
|
|
365
|
+
const nextMuted = !(player?.isMuted?.() ?? v.muted);
|
|
366
|
+
player?.setMuted?.(nextMuted);
|
|
367
|
+
v.muted = nextMuted;
|
|
368
|
+
isMuted = nextMuted;
|
|
369
|
+
if (nextMuted) {
|
|
370
|
+
volumeValue = 0;
|
|
371
|
+
} else {
|
|
372
|
+
volumeValue = Math.round((Number.isFinite(v.volume) ? v.volume : 1) * 100);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handleVolumeChange(val: number) {
|
|
377
|
+
if (disabled) return;
|
|
378
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
379
|
+
const v = player?.getVideoElement?.() ?? video;
|
|
380
|
+
if (!v) return;
|
|
381
|
+
// Validate: clamp to 0-100, handle NaN/Infinity (matches React implementation)
|
|
382
|
+
const next = Math.max(0, Math.min(100, val ?? 100));
|
|
383
|
+
if (!Number.isFinite(next)) return;
|
|
384
|
+
v.volume = next / 100;
|
|
385
|
+
v.muted = next === 0;
|
|
386
|
+
volumeValue = next;
|
|
387
|
+
isMuted = next === 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function handleSeekChange(val: number) {
|
|
391
|
+
if (disabled || !video) return;
|
|
392
|
+
if (isLive) {
|
|
393
|
+
const window = liveEdge - seekableStart;
|
|
394
|
+
const newTime = seekableStart + (val / 1000) * window;
|
|
395
|
+
if (onseek) {
|
|
396
|
+
onseek(newTime);
|
|
397
|
+
} else {
|
|
398
|
+
video.currentTime = newTime;
|
|
399
|
+
}
|
|
400
|
+
} else if (Number.isFinite(duration)) {
|
|
401
|
+
const newTime = (val / 1000) * duration;
|
|
402
|
+
if (onseek) {
|
|
403
|
+
onseek(newTime);
|
|
404
|
+
} else {
|
|
405
|
+
video.currentTime = newTime;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function handleFullscreen() {
|
|
411
|
+
if (disabled) return;
|
|
412
|
+
const container = document.querySelector('[data-player-container="true"]') as HTMLElement | null;
|
|
413
|
+
if (!container) return;
|
|
414
|
+
if (document.fullscreenElement) {
|
|
415
|
+
document.exitFullscreen().catch(() => {});
|
|
416
|
+
} else {
|
|
417
|
+
container.requestFullscreen().catch(() => {});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function handleGoLive() {
|
|
422
|
+
if (disabled || !video) return;
|
|
423
|
+
if (onJumpToLive) {
|
|
424
|
+
onJumpToLive();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
globalPlayerManager.getCurrentPlayer()?.jumpToLive?.();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function handleSpeedSelect(rate: number) {
|
|
431
|
+
if (disabled) return;
|
|
432
|
+
// Use findVideoElement for robust detection
|
|
433
|
+
const v = findVideoElement();
|
|
434
|
+
if (!v) return;
|
|
435
|
+
v.playbackRate = rate;
|
|
436
|
+
showSettingsMenu = false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function handleQualityChange(value: string) {
|
|
440
|
+
if (disabled) return;
|
|
441
|
+
qualityValue = value;
|
|
442
|
+
globalPlayerManager.getCurrentPlayer()?.selectQuality?.(value);
|
|
443
|
+
showSettingsMenu = false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function handleCaptionChange(value: string) {
|
|
447
|
+
if (disabled) return;
|
|
448
|
+
captionValue = value;
|
|
449
|
+
if (value === 'none') {
|
|
450
|
+
globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(null);
|
|
451
|
+
} else {
|
|
452
|
+
globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(value);
|
|
453
|
+
}
|
|
454
|
+
showSettingsMenu = false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Close menu when clicking outside - with debounce to prevent immediate close from same click
|
|
458
|
+
$effect(() => {
|
|
459
|
+
if (!showSettingsMenu) return;
|
|
460
|
+
|
|
461
|
+
const handleClick = (e: MouseEvent) => {
|
|
462
|
+
const target = e.target as HTMLElement;
|
|
463
|
+
if (!target.closest('.fw-settings-menu')) {
|
|
464
|
+
showSettingsMenu = false;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Debounce to prevent immediate close from the same click that opened the menu
|
|
469
|
+
const timeout = setTimeout(() => {
|
|
470
|
+
window.addEventListener('click', handleClick);
|
|
471
|
+
}, 0);
|
|
472
|
+
|
|
473
|
+
return () => {
|
|
474
|
+
clearTimeout(timeout);
|
|
475
|
+
window.removeEventListener('click', handleClick);
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
</script>
|
|
479
|
+
|
|
480
|
+
<div class={cn(
|
|
481
|
+
'fw-player-surface fw-controls-wrapper',
|
|
482
|
+
isVisible ? 'fw-controls-wrapper--visible' : 'fw-controls-wrapper--hidden'
|
|
483
|
+
)}>
|
|
484
|
+
<!-- Control bar -->
|
|
485
|
+
<div class="fw-control-bar pointer-events-auto" onclick={(e) => e.stopPropagation()}>
|
|
486
|
+
<!-- Seek bar -->
|
|
487
|
+
{#if canSeek}
|
|
488
|
+
<div class="fw-seek-wrapper">
|
|
489
|
+
<SeekBar
|
|
490
|
+
{currentTime}
|
|
491
|
+
{duration}
|
|
492
|
+
{buffered}
|
|
493
|
+
{disabled}
|
|
494
|
+
isLive={isLive}
|
|
495
|
+
{seekableStart}
|
|
496
|
+
{liveEdge}
|
|
497
|
+
{commitOnRelease}
|
|
498
|
+
onseek={(time) => {
|
|
499
|
+
if (onseek) {
|
|
500
|
+
onseek(time);
|
|
501
|
+
} else if (video) {
|
|
502
|
+
video.currentTime = time;
|
|
503
|
+
}
|
|
504
|
+
}}
|
|
505
|
+
/>
|
|
506
|
+
</div>
|
|
507
|
+
{/if}
|
|
508
|
+
|
|
509
|
+
<!-- Control buttons -->
|
|
510
|
+
<div class="fw-controls-row">
|
|
511
|
+
<!-- Left: Play, Skip, Volume, Time, Live -->
|
|
512
|
+
<div class="fw-controls-left">
|
|
513
|
+
<div class="fw-control-group">
|
|
514
|
+
<button type="button" class="fw-btn-flush" aria-label={isPlaying ? 'Pause' : 'Play'} onclick={handlePlayPause} disabled={disabled}>
|
|
515
|
+
{#if isPlaying}
|
|
516
|
+
<PauseIcon size={18} />
|
|
517
|
+
{:else}
|
|
518
|
+
<PlayIcon size={18} />
|
|
519
|
+
{/if}
|
|
520
|
+
</button>
|
|
521
|
+
{#if canSeek}
|
|
522
|
+
<button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip back 10s" onclick={handleSkipBack} disabled={disabled}>
|
|
523
|
+
<SkipBackIcon size={16} />
|
|
524
|
+
</button>
|
|
525
|
+
<button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip forward 10s" onclick={handleSkipForward} disabled={disabled}>
|
|
526
|
+
<SkipForwardIcon size={16} />
|
|
527
|
+
</button>
|
|
528
|
+
{/if}
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<!-- Volume -->
|
|
532
|
+
<div
|
|
533
|
+
class={cn(
|
|
534
|
+
'fw-volume-group',
|
|
535
|
+
isVolumeExpanded && 'fw-volume-group--expanded',
|
|
536
|
+
!hasAudio && 'fw-volume-group--disabled'
|
|
537
|
+
)}
|
|
538
|
+
onmouseenter={() => hasAudio && (isVolumeHovered = true)}
|
|
539
|
+
onmouseleave={() => { isVolumeHovered = false; isVolumeFocused = false; }}
|
|
540
|
+
onfocuscapture={() => hasAudio && (isVolumeFocused = true)}
|
|
541
|
+
onblurcapture={(e) => {
|
|
542
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
|
|
543
|
+
}}
|
|
544
|
+
onclick={(e) => {
|
|
545
|
+
if (disabled) return;
|
|
546
|
+
if (hasAudio && e.target === e.currentTarget) {
|
|
547
|
+
handleMute();
|
|
548
|
+
}
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
<button
|
|
552
|
+
type="button"
|
|
553
|
+
class="fw-volume-btn"
|
|
554
|
+
aria-label={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
|
|
555
|
+
title={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
|
|
556
|
+
onclick={handleMute}
|
|
557
|
+
disabled={!hasAudio}
|
|
558
|
+
>
|
|
559
|
+
<VolumeIcons isMuted={isMuted} volume={volumeValue / 100} size={16} />
|
|
560
|
+
</button>
|
|
561
|
+
<div class={cn(
|
|
562
|
+
'fw-volume-slider-wrapper',
|
|
563
|
+
isVolumeExpanded ? 'fw-volume-slider-wrapper--expanded' : 'fw-volume-slider-wrapper--collapsed'
|
|
564
|
+
)}>
|
|
565
|
+
<Slider
|
|
566
|
+
min={0}
|
|
567
|
+
max={100}
|
|
568
|
+
step={1}
|
|
569
|
+
value={isMuted ? 0 : volumeValue}
|
|
570
|
+
oninput={handleVolumeChange}
|
|
571
|
+
orientation="horizontal"
|
|
572
|
+
className="w-full"
|
|
573
|
+
aria-label="Volume"
|
|
574
|
+
disabled={!hasAudio}
|
|
575
|
+
/>
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<div class="fw-control-group">
|
|
580
|
+
<span class="fw-time-display">
|
|
581
|
+
{timeDisplay}
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
584
|
+
|
|
585
|
+
{#if isLive}
|
|
586
|
+
<div class="fw-control-group">
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
onclick={handleGoLive}
|
|
590
|
+
disabled={!hasDvrWindow || isNearLiveState}
|
|
591
|
+
class={cn(
|
|
592
|
+
'fw-live-badge',
|
|
593
|
+
(!hasDvrWindow || isNearLiveState) ? 'fw-live-badge--active' : 'fw-live-badge--behind'
|
|
594
|
+
)}
|
|
595
|
+
title={!hasDvrWindow ? 'Live only' : (isNearLiveState ? 'At live edge' : 'Jump to live')}
|
|
596
|
+
>
|
|
597
|
+
LIVE
|
|
598
|
+
{#if !isNearLiveState && hasDvrWindow}
|
|
599
|
+
<SeekToLiveIcon size={10} />
|
|
600
|
+
{/if}
|
|
601
|
+
</button>
|
|
602
|
+
</div>
|
|
603
|
+
{/if}
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<!-- Right: Stats, Settings, Fullscreen -->
|
|
607
|
+
<div class="fw-controls-right">
|
|
608
|
+
{#if showStatsButton}
|
|
609
|
+
<div class="fw-control-group">
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
class={cn('fw-btn-flush', isStatsOpen && 'fw-btn-flush--active')}
|
|
613
|
+
aria-label="Toggle stats"
|
|
614
|
+
title="Stats"
|
|
615
|
+
onclick={onStatsToggle}
|
|
616
|
+
disabled={disabled}
|
|
617
|
+
>
|
|
618
|
+
<StatsIcon size={16} />
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
{/if}
|
|
622
|
+
<div class="fw-control-group relative">
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
class={cn('fw-btn-flush group', showSettingsMenu && 'fw-btn-flush--active')}
|
|
626
|
+
aria-label="Settings"
|
|
627
|
+
title="Settings"
|
|
628
|
+
onclick={() => showSettingsMenu = !showSettingsMenu}
|
|
629
|
+
disabled={disabled}
|
|
630
|
+
>
|
|
631
|
+
<SettingsIcon size={16} class="transition-transform group-hover:rotate-90" />
|
|
632
|
+
</button>
|
|
633
|
+
|
|
634
|
+
{#if showSettingsMenu}
|
|
635
|
+
<div class="fw-player-surface fw-settings-menu">
|
|
636
|
+
<!-- Playback Mode - only show for live content (not VOD/clips) -->
|
|
637
|
+
{#if onModeChange && isContentLive !== false}
|
|
638
|
+
<div class="fw-settings-section">
|
|
639
|
+
<div class="fw-settings-label">Mode</div>
|
|
640
|
+
<div class="fw-settings-options">
|
|
641
|
+
{#each ['auto', 'low-latency', 'quality'] as mode}
|
|
642
|
+
<button
|
|
643
|
+
type="button"
|
|
644
|
+
class={cn(
|
|
645
|
+
'fw-settings-btn',
|
|
646
|
+
playbackMode === mode && 'fw-settings-btn--active'
|
|
647
|
+
)}
|
|
648
|
+
onclick={() => { onModeChange(mode as PlaybackMode); showSettingsMenu = false; }}
|
|
649
|
+
>
|
|
650
|
+
{mode === 'low-latency' ? 'Fast' : mode === 'quality' ? 'Stable' : 'Auto'}
|
|
651
|
+
</button>
|
|
652
|
+
{/each}
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
{/if}
|
|
656
|
+
|
|
657
|
+
<!-- Speed (hidden for WebRTC MediaStream) -->
|
|
658
|
+
{#if supportsPlaybackRate}
|
|
659
|
+
<div class="fw-settings-section">
|
|
660
|
+
<div class="fw-settings-label">Speed</div>
|
|
661
|
+
<div class="fw-settings-options fw-settings-options--wrap">
|
|
662
|
+
{#each SPEED_PRESETS as rate}
|
|
663
|
+
<button
|
|
664
|
+
type="button"
|
|
665
|
+
class={cn(
|
|
666
|
+
'fw-settings-btn',
|
|
667
|
+
playbackRate === rate && 'fw-settings-btn--active'
|
|
668
|
+
)}
|
|
669
|
+
onclick={() => handleSpeedSelect(rate)}
|
|
670
|
+
disabled={disabled}
|
|
671
|
+
>
|
|
672
|
+
{rate}x
|
|
673
|
+
</button>
|
|
674
|
+
{/each}
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
{/if}
|
|
678
|
+
|
|
679
|
+
<!-- Quality -->
|
|
680
|
+
{#if qualities.length > 0}
|
|
681
|
+
<div class="fw-settings-section">
|
|
682
|
+
<div class="fw-settings-label">Quality</div>
|
|
683
|
+
<div class="fw-settings-list">
|
|
684
|
+
<button
|
|
685
|
+
class={cn(
|
|
686
|
+
'fw-settings-list-item',
|
|
687
|
+
qualityValue === 'auto' && 'fw-settings-list-item--active'
|
|
688
|
+
)}
|
|
689
|
+
onclick={() => handleQualityChange('auto')}
|
|
690
|
+
>
|
|
691
|
+
Auto
|
|
692
|
+
</button>
|
|
693
|
+
{#each qualities as q}
|
|
694
|
+
<button
|
|
695
|
+
class={cn(
|
|
696
|
+
'fw-settings-list-item',
|
|
697
|
+
qualityValue === q.id && 'fw-settings-list-item--active'
|
|
698
|
+
)}
|
|
699
|
+
onclick={() => handleQualityChange(q.id)}
|
|
700
|
+
>
|
|
701
|
+
{q.label}
|
|
702
|
+
</button>
|
|
703
|
+
{/each}
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
{/if}
|
|
707
|
+
|
|
708
|
+
<!-- Captions -->
|
|
709
|
+
{#if textTracks.length > 0}
|
|
710
|
+
<div class="fw-settings-section">
|
|
711
|
+
<div class="fw-settings-label">Captions</div>
|
|
712
|
+
<div class="fw-settings-list">
|
|
713
|
+
<button
|
|
714
|
+
class={cn(
|
|
715
|
+
'fw-settings-list-item',
|
|
716
|
+
captionValue === 'none' && 'fw-settings-list-item--active'
|
|
717
|
+
)}
|
|
718
|
+
onclick={() => handleCaptionChange('none')}
|
|
719
|
+
>
|
|
720
|
+
Off
|
|
721
|
+
</button>
|
|
722
|
+
{#each textTracks as t}
|
|
723
|
+
<button
|
|
724
|
+
class={cn(
|
|
725
|
+
'fw-settings-list-item',
|
|
726
|
+
captionValue === t.id && 'fw-settings-list-item--active'
|
|
727
|
+
)}
|
|
728
|
+
onclick={() => handleCaptionChange(t.id)}
|
|
729
|
+
>
|
|
730
|
+
{t.label || t.id}
|
|
731
|
+
</button>
|
|
732
|
+
{/each}
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
{/if}
|
|
736
|
+
</div>
|
|
737
|
+
{/if}
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
<div class="fw-control-group">
|
|
741
|
+
<button type="button" class="fw-btn-flush" aria-label="Toggle fullscreen" title="Fullscreen" onclick={handleFullscreen} disabled={disabled}>
|
|
742
|
+
{#if isFullscreen}
|
|
743
|
+
<FullscreenExitIcon size={16} />
|
|
744
|
+
{:else}
|
|
745
|
+
<FullscreenIcon size={16} />
|
|
746
|
+
{/if}
|
|
747
|
+
</button>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|