@structyl/video-player 1.0.0

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/index.cjs ADDED
@@ -0,0 +1,1359 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var lucideReact = require('lucide-react');
5
+ var styled = require('@structyl/styled');
6
+ var jsxRuntime = require('react/jsx-runtime');
7
+ var Hls = require('hls.js');
8
+
9
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
+
11
+ var Hls__default = /*#__PURE__*/_interopDefault(Hls);
12
+
13
+ // src/VideoPlayer.tsx
14
+ var ChapterMarkers = ({
15
+ chapters,
16
+ duration,
17
+ currentTime,
18
+ onSeek
19
+ }) => {
20
+ if (chapters.length === 0 || duration === 0) return null;
21
+ return /* @__PURE__ */ jsxRuntime.jsx(styled.Tooltip.Provider, { delayDuration: 200, children: chapters.map((chapter) => {
22
+ const left = chapter.startTime / duration * 100;
23
+ const isActive = currentTime >= chapter.startTime && currentTime < chapter.endTime;
24
+ return /* @__PURE__ */ jsxRuntime.jsxs(styled.Tooltip.Root, { children: [
25
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tooltip.Trigger, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(
26
+ "div",
27
+ {
28
+ className: `absolute -top-px h-[calc(100%+4px)] border-l-2 pointer-events-auto cursor-pointer transition-colors duration-200 z-[1] ${isActive ? "border-primary" : "border-fg/50 hover:border-primary"}`,
29
+ style: { left: `${left}%` },
30
+ onClick: (e) => {
31
+ e.stopPropagation();
32
+ onSeek(chapter.startTime);
33
+ }
34
+ }
35
+ ) }),
36
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tooltip.Content, { side: "top", variant: "dark", children: chapter.title })
37
+ ] }, chapter.id);
38
+ }) });
39
+ };
40
+ ChapterMarkers.displayName = "ChapterMarkers";
41
+ var formatTime = (seconds) => {
42
+ if (isNaN(seconds) || !isFinite(seconds)) return "0:00";
43
+ const h = Math.floor(seconds / 3600);
44
+ const m = Math.floor(seconds % 3600 / 60);
45
+ const s = Math.floor(seconds % 60);
46
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
47
+ return `${m}:${String(s).padStart(2, "0")}`;
48
+ };
49
+ var VideoControls = ({
50
+ isPlaying,
51
+ currentTime,
52
+ duration,
53
+ volume,
54
+ isMuted,
55
+ isFullscreen,
56
+ isPiP,
57
+ buffered,
58
+ subtitlesEnabled,
59
+ hasSubtitles,
60
+ hasPlaylist,
61
+ onPlayPause,
62
+ onSeek,
63
+ onVolumeChange,
64
+ onMuteToggle,
65
+ onFullscreenToggle,
66
+ onPiPToggle,
67
+ onSubtitlesToggle,
68
+ onPlaylistClick,
69
+ onNext,
70
+ onPrevious,
71
+ chapters,
72
+ settingsPanel,
73
+ onThumbnailHover
74
+ }) => {
75
+ const progress = duration > 0 ? currentTime / duration * 100 : 0;
76
+ const progressRef = react.useRef(null);
77
+ const handleProgressClick = (e) => {
78
+ const rect = progressRef.current?.getBoundingClientRect();
79
+ if (!rect) return;
80
+ onSeek((e.clientX - rect.left) / rect.width * duration);
81
+ };
82
+ const handleProgressHover = (e) => {
83
+ const rect = progressRef.current?.getBoundingClientRect();
84
+ if (!rect) return;
85
+ const pos = (e.clientX - rect.left) / rect.width;
86
+ onThumbnailHover(true, pos * duration, pos * 100);
87
+ };
88
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bg-black/70 pt-2 pb-2 px-2 md:pb-2.5 md:px-3", children: [
89
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-2 cursor-pointer py-1", children: /* @__PURE__ */ jsxRuntime.jsxs(
90
+ "div",
91
+ {
92
+ ref: progressRef,
93
+ className: "group relative h-1 bg-fg/20 rounded cursor-pointer transition-all duration-150 hover:h-1.5",
94
+ onClick: handleProgressClick,
95
+ onMouseMove: handleProgressHover,
96
+ onMouseLeave: () => onThumbnailHover(false, 0, 0),
97
+ children: [
98
+ /* @__PURE__ */ jsxRuntime.jsx(ChapterMarkers, { chapters, duration, currentTime, onSeek }),
99
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute top-0 left-0 h-full bg-fg/35 rounded transition-[width] duration-100", style: { width: `${buffered}%` } }),
100
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute top-0 left-0 h-full bg-primary rounded transition-[width] duration-100", style: { width: `${progress}%` } }),
101
+ /* @__PURE__ */ jsxRuntime.jsx(
102
+ "div",
103
+ {
104
+ className: "absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-primary rounded-full shadow-md opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-100",
105
+ style: { left: `${progress}%` }
106
+ }
107
+ )
108
+ ]
109
+ }
110
+ ) }),
111
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
112
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5 flex-1", children: [
113
+ hasPlaylist && /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onPrevious, title: "Previous", className: "text-white/80 hover:text-white hover:bg-white/10", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronLeft, {}) }),
114
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onPlayPause, title: isPlaying ? "Pause (Space)" : "Play (Space)", className: "text-white/80 hover:text-white hover:bg-white/10", children: isPlaying ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pause, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Play, {}) }),
115
+ hasPlaylist && /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onNext, title: "Next", className: "text-white/80 hover:text-white hover:bg-white/10", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronRight, {}) }),
116
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: () => onSeek(Math.max(0, currentTime - 10)), title: "Rewind 10s", className: "text-white/80 hover:text-white hover:bg-white/10", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.SkipBack, {}) }),
117
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: () => onSeek(Math.min(duration, currentTime + 10)), title: "Forward 10s", className: "text-white/80 hover:text-white hover:bg-white/10", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.SkipForward, {}) }),
118
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5 group", children: [
119
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onMuteToggle, title: "Mute (M)", className: "text-white/80 hover:text-white hover:bg-white/10", children: isMuted || volume === 0 ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.VolumeX, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Volume2, {}) }),
120
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 flex items-center sm:group-hover:w-[88px] sm:group-hover:opacity-100", children: /* @__PURE__ */ jsxRuntime.jsx(
121
+ styled.Slider,
122
+ {
123
+ value: [isMuted ? 0 : Math.round(volume * 100)],
124
+ onValueChange: (val) => onVolumeChange((val[0] ?? 0) / 100),
125
+ min: 0,
126
+ max: 100,
127
+ step: 1,
128
+ className: "w-20"
129
+ }
130
+ ) })
131
+ ] }),
132
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-white/90 text-[11px] md:text-xs font-medium whitespace-nowrap px-1.5 tabular-nums", children: [
133
+ formatTime(currentTime),
134
+ " / ",
135
+ formatTime(duration)
136
+ ] })
137
+ ] }),
138
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5", children: [
139
+ hasSubtitles && /* @__PURE__ */ jsxRuntime.jsx(
140
+ styled.Button,
141
+ {
142
+ variant: "ghost",
143
+ size: "icon",
144
+ onClick: onSubtitlesToggle,
145
+ title: "Subtitles (C)",
146
+ className: subtitlesEnabled ? "text-primary hover:bg-white/10" : "text-white/80 hover:text-white hover:bg-white/10",
147
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Subtitles, {})
148
+ }
149
+ ),
150
+ hasPlaylist && /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onPlaylistClick, title: "Playlist", className: "text-white/80 hover:text-white hover:bg-white/10", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.List, {}) }),
151
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.DropdownMenu.Root, { children: [
152
+ /* @__PURE__ */ jsxRuntime.jsx(styled.DropdownMenu.Trigger, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", title: "Settings", className: "text-white/80 hover:text-white hover:bg-white/10 data-[state=open]:text-primary", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, {}) }) }),
153
+ /* @__PURE__ */ jsxRuntime.jsx(
154
+ styled.DropdownMenu.Content,
155
+ {
156
+ side: "top",
157
+ align: "end",
158
+ sideOffset: 8,
159
+ className: "p-0 w-[280px] max-h-[420px] overflow-y-auto bg-popover",
160
+ children: settingsPanel
161
+ }
162
+ )
163
+ ] }),
164
+ /* @__PURE__ */ jsxRuntime.jsx(
165
+ styled.Button,
166
+ {
167
+ variant: "ghost",
168
+ size: "icon",
169
+ onClick: onPiPToggle,
170
+ title: "Picture in Picture (P)",
171
+ className: isPiP ? "text-primary hover:bg-white/10" : "text-white/80 hover:text-white hover:bg-white/10",
172
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PictureInPicture, {})
173
+ }
174
+ ),
175
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon", onClick: onFullscreenToggle, title: "Fullscreen (F)", className: "text-white/80 hover:text-white hover:bg-white/10", children: isFullscreen ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Minimize, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Maximize, {}) })
176
+ ] })
177
+ ] })
178
+ ] });
179
+ };
180
+ VideoControls.displayName = "VideoControls";
181
+ var FILTER_CONFIG = [
182
+ { key: "brightness", label: "Brightness", min: 0, max: 200, unit: "%" },
183
+ { key: "contrast", label: "Contrast", min: 0, max: 200, unit: "%" },
184
+ { key: "saturation", label: "Saturation", min: 0, max: 200, unit: "%" },
185
+ { key: "hue", label: "Hue", min: 0, max: 360, unit: "\xB0" },
186
+ { key: "blur", label: "Blur", min: 0, max: 10, unit: "px" },
187
+ { key: "grayscale", label: "Grayscale", min: 0, max: 100, unit: "%" }
188
+ ];
189
+ var VideoFilters = ({ filters, onFilterChange, onReset }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
190
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-3.5", children: [
191
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-fg", children: "Video Filters" }),
192
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "sm", onClick: onReset, leftIcon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.RotateCcw, {}), children: "Reset" })
193
+ ] }),
194
+ FILTER_CONFIG.map(({ key, label, min, max, unit }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
195
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-1.5", children: [
196
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground", children: label }),
197
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[11px] text-fg font-medium", children: [
198
+ filters[key],
199
+ unit
200
+ ] })
201
+ ] }),
202
+ /* @__PURE__ */ jsxRuntime.jsx(
203
+ styled.Slider,
204
+ {
205
+ value: [filters[key]],
206
+ onValueChange: ([v]) => onFilterChange(key, v ?? filters[key]),
207
+ min,
208
+ max,
209
+ step: 1
210
+ }
211
+ )
212
+ ] }, key))
213
+ ] });
214
+ VideoFilters.displayName = "VideoFilters";
215
+ var SubtitleUploader = ({ onSubtitleUpload }) => {
216
+ const fileInputRef = react.useRef(null);
217
+ const handleFileChange = (e) => {
218
+ const file = e.target.files?.[0];
219
+ if (file && (file.name.endsWith(".srt") || file.name.endsWith(".vtt"))) {
220
+ onSubtitleUpload(file);
221
+ }
222
+ };
223
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
224
+ /* @__PURE__ */ jsxRuntime.jsx(
225
+ "input",
226
+ {
227
+ ref: fileInputRef,
228
+ type: "file",
229
+ accept: ".srt,.vtt",
230
+ onChange: handleFileChange,
231
+ style: { display: "none" }
232
+ }
233
+ ),
234
+ /* @__PURE__ */ jsxRuntime.jsx(
235
+ styled.Button,
236
+ {
237
+ variant: "outline",
238
+ size: "sm",
239
+ className: "w-full mb-2",
240
+ leftIcon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Upload, {}),
241
+ onClick: () => fileInputRef.current?.click(),
242
+ children: "Upload Subtitle File"
243
+ }
244
+ ),
245
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[11px] text-muted-foreground", children: "Supports .srt and .vtt formats" })
246
+ ] });
247
+ };
248
+ SubtitleUploader.displayName = "SubtitleUploader";
249
+ var PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
250
+ var VideoSettings = ({
251
+ playbackRate,
252
+ subtitleStyle,
253
+ subtitles,
254
+ activeSubtitleIndex,
255
+ filters,
256
+ qualities,
257
+ currentQuality,
258
+ onPlaybackRateChange,
259
+ onSubtitleStyleChange,
260
+ onSubtitleTrackChange,
261
+ onSubtitleUpload,
262
+ onFilterChange,
263
+ onResetFilters,
264
+ onQualityChange
265
+ }) => {
266
+ const updateSubtitleStyle = (updates) => {
267
+ onSubtitleStyleChange({ ...subtitleStyle, ...updates });
268
+ };
269
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col w-full", children: [
270
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center px-3 py-2.5 border-b border-border shrink-0", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[13px] font-semibold text-fg", children: "Settings" }) }),
271
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Root, { defaultValue: "subtitles", className: "flex flex-col flex-1 overflow-hidden", children: [
272
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.List, { className: "px-3 py-2 flex-shrink-0", children: [
273
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "subtitles", children: "Subtitles" }),
274
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "filters", children: "Filters" }),
275
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "quality", children: "Quality" }),
276
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "playback", children: "Playback" })
277
+ ] }),
278
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1 overflow-y-auto px-3 pt-1 pb-3", children: [
279
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Content, { value: "subtitles", children: /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Root, { defaultValue: "style", variant: "pills", children: [
280
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.List, { className: "mb-3", children: [
281
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "style", children: "Style" }),
282
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "tracks", children: "Tracks" }),
283
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Trigger, { value: "upload", children: "Upload" })
284
+ ] }),
285
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Content, { value: "style", children: [
286
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
287
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-1.5", children: [
288
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground", children: "Font Size" }),
289
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[11px] text-fg font-medium", children: [
290
+ subtitleStyle.fontSize,
291
+ "px"
292
+ ] })
293
+ ] }),
294
+ /* @__PURE__ */ jsxRuntime.jsx(
295
+ styled.Slider,
296
+ {
297
+ value: [subtitleStyle.fontSize],
298
+ onValueChange: ([v]) => updateSubtitleStyle({ fontSize: v ?? subtitleStyle.fontSize }),
299
+ min: 14,
300
+ max: 40,
301
+ step: 2
302
+ }
303
+ )
304
+ ] }),
305
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
306
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-1.5", children: [
307
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground", children: "Background Opacity" }),
308
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[11px] text-fg font-medium", children: [
309
+ Math.round(subtitleStyle.backgroundOpacity * 100),
310
+ "%"
311
+ ] })
312
+ ] }),
313
+ /* @__PURE__ */ jsxRuntime.jsx(
314
+ styled.Slider,
315
+ {
316
+ value: [Math.round(subtitleStyle.backgroundOpacity * 100)],
317
+ onValueChange: ([v]) => updateSubtitleStyle({ backgroundOpacity: (v ?? 0) / 100 }),
318
+ min: 0,
319
+ max: 100,
320
+ step: 5
321
+ }
322
+ )
323
+ ] }),
324
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
325
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground block mb-1.5", children: "Text Color" }),
326
+ /* @__PURE__ */ jsxRuntime.jsx(
327
+ "input",
328
+ {
329
+ type: "color",
330
+ className: "w-full h-9 rounded-lg border border-border cursor-pointer bg-transparent p-0.5",
331
+ value: subtitleStyle.textColor,
332
+ onChange: (e) => updateSubtitleStyle({ textColor: e.target.value })
333
+ }
334
+ )
335
+ ] }),
336
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
337
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground block mb-1.5", children: "Background Color" }),
338
+ /* @__PURE__ */ jsxRuntime.jsx(
339
+ "input",
340
+ {
341
+ type: "color",
342
+ className: "w-full h-9 rounded-lg border border-border cursor-pointer bg-transparent p-0.5",
343
+ value: subtitleStyle.backgroundColor,
344
+ onChange: (e) => updateSubtitleStyle({ backgroundColor: e.target.value })
345
+ }
346
+ )
347
+ ] }),
348
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-3.5 last:mb-0", children: [
349
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] text-muted-foreground block mb-2", children: "Position" }),
350
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
351
+ /* @__PURE__ */ jsxRuntime.jsx(
352
+ styled.Button,
353
+ {
354
+ variant: subtitleStyle.position === "bottom" ? "default" : "outline",
355
+ size: "sm",
356
+ className: "flex-1",
357
+ onClick: () => updateSubtitleStyle({ position: "bottom" }),
358
+ children: "Bottom"
359
+ }
360
+ ),
361
+ /* @__PURE__ */ jsxRuntime.jsx(
362
+ styled.Button,
363
+ {
364
+ variant: subtitleStyle.position === "top" ? "default" : "outline",
365
+ size: "sm",
366
+ className: "flex-1",
367
+ onClick: () => updateSubtitleStyle({ position: "top" }),
368
+ children: "Top"
369
+ }
370
+ )
371
+ ] })
372
+ ] })
373
+ ] }),
374
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Content, { value: "tracks", children: [
375
+ /* @__PURE__ */ jsxRuntime.jsx(
376
+ styled.Button,
377
+ {
378
+ variant: activeSubtitleIndex === -1 ? "default" : "outline",
379
+ size: "sm",
380
+ className: "w-full justify-start mb-1",
381
+ onClick: () => onSubtitleTrackChange(-1),
382
+ children: "Off"
383
+ }
384
+ ),
385
+ subtitles.map((track, i) => /* @__PURE__ */ jsxRuntime.jsxs(
386
+ styled.Button,
387
+ {
388
+ variant: activeSubtitleIndex === i ? "default" : "outline",
389
+ size: "sm",
390
+ className: "w-full justify-start mb-1",
391
+ onClick: () => onSubtitleTrackChange(i),
392
+ children: [
393
+ track.label,
394
+ " ",
395
+ track.language && `(${track.language})`
396
+ ]
397
+ },
398
+ track.src
399
+ ))
400
+ ] }),
401
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Content, { value: "upload", children: /* @__PURE__ */ jsxRuntime.jsx(SubtitleUploader, { onSubtitleUpload }) })
402
+ ] }) }),
403
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Tabs.Content, { value: "filters", children: /* @__PURE__ */ jsxRuntime.jsx(VideoFilters, { filters, onFilterChange, onReset: onResetFilters }) }),
404
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Content, { value: "quality", children: [
405
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs font-semibold text-fg mb-2.5", children: "Video Quality" }),
406
+ qualities.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
407
+ /* @__PURE__ */ jsxRuntime.jsx(
408
+ styled.Button,
409
+ {
410
+ variant: currentQuality === -1 ? "default" : "outline",
411
+ size: "sm",
412
+ className: "w-full justify-start mb-1",
413
+ onClick: () => onQualityChange(-1),
414
+ children: "Auto"
415
+ }
416
+ ),
417
+ qualities.map((q, i) => /* @__PURE__ */ jsxRuntime.jsxs(
418
+ styled.Button,
419
+ {
420
+ variant: currentQuality === i ? "default" : "outline",
421
+ size: "sm",
422
+ className: "w-full justify-start mb-1",
423
+ onClick: () => onQualityChange(i),
424
+ children: [
425
+ q.label,
426
+ " (",
427
+ Math.round(q.bitrate / 1e3),
428
+ "kbps)"
429
+ ]
430
+ },
431
+ i
432
+ ))
433
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "Quality selection is only available for HLS/DASH adaptive streaming sources." })
434
+ ] }),
435
+ /* @__PURE__ */ jsxRuntime.jsxs(styled.Tabs.Content, { value: "playback", children: [
436
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs font-semibold text-fg mb-2.5", children: "Playback Speed" }),
437
+ PLAYBACK_RATES.map((rate) => /* @__PURE__ */ jsxRuntime.jsx(
438
+ styled.Button,
439
+ {
440
+ variant: playbackRate === rate ? "default" : "outline",
441
+ size: "sm",
442
+ className: "w-full justify-start mb-1",
443
+ onClick: () => onPlaybackRateChange(rate),
444
+ children: rate === 1 ? "Normal" : `${rate}x`
445
+ },
446
+ rate
447
+ ))
448
+ ] })
449
+ ] })
450
+ ] })
451
+ ] });
452
+ };
453
+ VideoSettings.displayName = "VideoSettings";
454
+ var useVideoPlayer = ({
455
+ videoRef,
456
+ containerRef,
457
+ autoPlay,
458
+ muted,
459
+ onPlay,
460
+ onPause,
461
+ onEnded,
462
+ onTimeUpdate,
463
+ onVolumeChange
464
+ }) => {
465
+ const [isPlaying, setIsPlaying] = react.useState(false);
466
+ const [currentTime, setCurrentTime] = react.useState(0);
467
+ const [duration, setDuration] = react.useState(0);
468
+ const [volume, setVolume] = react.useState(1);
469
+ const [isMuted, setIsMuted] = react.useState(muted || false);
470
+ const [playbackRate, setPlaybackRate] = react.useState(1);
471
+ const [isFullscreen, setIsFullscreen] = react.useState(false);
472
+ const [isPiP, setIsPiP] = react.useState(false);
473
+ const [buffered, setBuffered] = react.useState(0);
474
+ react.useEffect(() => {
475
+ const video = videoRef.current;
476
+ if (!video) return;
477
+ if (autoPlay) {
478
+ video.play().catch(console.error);
479
+ }
480
+ const handleLoadedMetadata = () => setDuration(video.duration);
481
+ const handleTimeUpdate = () => {
482
+ setCurrentTime(video.currentTime);
483
+ onTimeUpdate?.(video.currentTime);
484
+ };
485
+ const handlePlay = () => {
486
+ setIsPlaying(true);
487
+ onPlay?.();
488
+ };
489
+ const handlePause = () => {
490
+ setIsPlaying(false);
491
+ onPause?.();
492
+ };
493
+ const handleEnded = () => {
494
+ setIsPlaying(false);
495
+ onEnded?.();
496
+ };
497
+ const handleVolumeChange2 = () => {
498
+ setVolume(video.volume);
499
+ setIsMuted(video.muted);
500
+ onVolumeChange?.(video.volume);
501
+ };
502
+ const handleProgress = () => {
503
+ if (video.buffered.length > 0) {
504
+ const bufferedEnd = video.buffered.end(video.buffered.length - 1);
505
+ setBuffered(bufferedEnd / video.duration * 100);
506
+ }
507
+ };
508
+ const handleEnterpip = () => setIsPiP(true);
509
+ const handleLeavepip = () => setIsPiP(false);
510
+ video.addEventListener("loadedmetadata", handleLoadedMetadata);
511
+ video.addEventListener("timeupdate", handleTimeUpdate);
512
+ video.addEventListener("play", handlePlay);
513
+ video.addEventListener("pause", handlePause);
514
+ video.addEventListener("ended", handleEnded);
515
+ video.addEventListener("volumechange", handleVolumeChange2);
516
+ video.addEventListener("progress", handleProgress);
517
+ video.addEventListener("enterpictureinpicture", handleEnterpip);
518
+ video.addEventListener("leavepictureinpicture", handleLeavepip);
519
+ return () => {
520
+ video.removeEventListener("loadedmetadata", handleLoadedMetadata);
521
+ video.removeEventListener("timeupdate", handleTimeUpdate);
522
+ video.removeEventListener("play", handlePlay);
523
+ video.removeEventListener("pause", handlePause);
524
+ video.removeEventListener("ended", handleEnded);
525
+ video.removeEventListener("volumechange", handleVolumeChange2);
526
+ video.removeEventListener("progress", handleProgress);
527
+ video.removeEventListener("enterpictureinpicture", handleEnterpip);
528
+ video.removeEventListener("leavepictureinpicture", handleLeavepip);
529
+ };
530
+ }, [videoRef, autoPlay, onPlay, onPause, onEnded, onTimeUpdate, onVolumeChange]);
531
+ react.useEffect(() => {
532
+ const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
533
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
534
+ return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
535
+ }, []);
536
+ const togglePlay = react.useCallback(() => {
537
+ const video = videoRef.current;
538
+ if (!video) return;
539
+ if (!video.paused) {
540
+ video.pause();
541
+ return;
542
+ }
543
+ if (video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
544
+ video.load();
545
+ }
546
+ video.play().catch(console.error);
547
+ }, [videoRef]);
548
+ const handleSeek = react.useCallback((time) => {
549
+ const video = videoRef.current;
550
+ if (!video) return;
551
+ video.currentTime = time;
552
+ setCurrentTime(time);
553
+ }, [videoRef]);
554
+ const handleVolumeChange = react.useCallback((newVolume) => {
555
+ const video = videoRef.current;
556
+ if (!video) return;
557
+ video.volume = newVolume;
558
+ setVolume(newVolume);
559
+ if (newVolume > 0 && isMuted) {
560
+ video.muted = false;
561
+ setIsMuted(false);
562
+ }
563
+ }, [videoRef, isMuted]);
564
+ const toggleMute = react.useCallback(() => {
565
+ const video = videoRef.current;
566
+ if (!video) return;
567
+ video.muted = !video.muted;
568
+ setIsMuted(video.muted);
569
+ }, [videoRef]);
570
+ const changePlaybackRate = react.useCallback((rate) => {
571
+ const video = videoRef.current;
572
+ if (!video) return;
573
+ video.playbackRate = rate;
574
+ setPlaybackRate(rate);
575
+ }, [videoRef]);
576
+ const toggleFullscreen = react.useCallback(async () => {
577
+ const container = containerRef.current;
578
+ if (!container) return;
579
+ try {
580
+ if (!document.fullscreenElement) {
581
+ await container.requestFullscreen();
582
+ } else {
583
+ await document.exitFullscreen();
584
+ }
585
+ } catch (error) {
586
+ console.error("Fullscreen error:", error);
587
+ }
588
+ }, [containerRef]);
589
+ const togglePiP = react.useCallback(async () => {
590
+ const video = videoRef.current;
591
+ if (!video) return;
592
+ try {
593
+ if (document.pictureInPictureElement) {
594
+ await document.exitPictureInPicture();
595
+ } else {
596
+ await video.requestPictureInPicture();
597
+ }
598
+ } catch (error) {
599
+ console.error("PiP error:", error);
600
+ }
601
+ }, [videoRef]);
602
+ const skipForward = react.useCallback(() => {
603
+ const video = videoRef.current;
604
+ if (!video) return;
605
+ video.currentTime = Math.min(video.duration, video.currentTime + 10);
606
+ }, [videoRef]);
607
+ const skipBackward = react.useCallback(() => {
608
+ const video = videoRef.current;
609
+ if (!video) return;
610
+ video.currentTime = Math.max(0, video.currentTime - 10);
611
+ }, [videoRef]);
612
+ return {
613
+ isPlaying,
614
+ currentTime,
615
+ duration,
616
+ volume,
617
+ isMuted,
618
+ playbackRate,
619
+ isFullscreen,
620
+ isPiP,
621
+ buffered,
622
+ togglePlay,
623
+ handleSeek,
624
+ handleVolumeChange,
625
+ toggleMute,
626
+ changePlaybackRate,
627
+ toggleFullscreen,
628
+ togglePiP,
629
+ skipForward,
630
+ skipBackward
631
+ };
632
+ };
633
+ var toHex2 = (n) => Math.round(n).toString(16).padStart(2, "0");
634
+ var SubtitleDisplay = ({ text, style }) => {
635
+ if (!text) return null;
636
+ const bgColor = `${style.backgroundColor}${toHex2(style.backgroundOpacity * 255)}`;
637
+ const positionClass = style.position === "bottom" ? "bottom-[88px]" : "top-4";
638
+ return /* @__PURE__ */ jsxRuntime.jsx(
639
+ "div",
640
+ {
641
+ className: `absolute left-1/2 -translate-x-1/2 max-w-[90%] px-3 py-1.5 rounded-lg text-center pointer-events-none z-20 leading-[1.4] break-words ${positionClass}`,
642
+ style: {
643
+ fontSize: style.fontSize,
644
+ fontFamily: style.fontFamily,
645
+ color: style.textColor,
646
+ backgroundColor: bgColor,
647
+ textShadow: "2px 2px 4px rgba(0,0,0,0.8)"
648
+ },
649
+ children: text.split("\n").map((line, i) => /* @__PURE__ */ jsxRuntime.jsx("div", { children: line }, i))
650
+ }
651
+ );
652
+ };
653
+ SubtitleDisplay.displayName = "SubtitleDisplay";
654
+
655
+ // src/subtitleParser.ts
656
+ var toSeconds = (h, m, s, ms) => parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseInt(s, 10) + parseInt(ms, 10) / 1e3;
657
+ var parseSRT = (content) => {
658
+ const cues = [];
659
+ const SRT_TIME = /(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/;
660
+ for (const block of content.trim().split(/\n\s*\n/)) {
661
+ const lines = block.split("\n");
662
+ if (lines.length < 3) continue;
663
+ const timeLine = lines[1];
664
+ if (!timeLine) continue;
665
+ const m = timeLine.match(SRT_TIME);
666
+ if (!m || m.length < 9) continue;
667
+ const start = toSeconds(m[1] ?? "0", m[2] ?? "0", m[3] ?? "0", m[4] ?? "0");
668
+ const end = toSeconds(m[5] ?? "0", m[6] ?? "0", m[7] ?? "0", m[8] ?? "0");
669
+ const text = lines.slice(2).join("\n").trim();
670
+ cues.push({ start, end, text });
671
+ }
672
+ return cues;
673
+ };
674
+ var parseVTT = (content) => {
675
+ const cues = [];
676
+ const VTT_TIME = /(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})/;
677
+ const lines = content.split("\n");
678
+ let i = 0;
679
+ while (i < lines.length && !lines[i]?.includes("-->")) i++;
680
+ while (i < lines.length) {
681
+ const line = lines[i]?.trim() ?? "";
682
+ const m = line.match(VTT_TIME);
683
+ if (m && m.length >= 9) {
684
+ const start = toSeconds(m[1] ?? "0", m[2] ?? "0", m[3] ?? "0", m[4] ?? "0");
685
+ const end = toSeconds(m[5] ?? "0", m[6] ?? "0", m[7] ?? "0", m[8] ?? "0");
686
+ const textLines = [];
687
+ i++;
688
+ while (i < lines.length && lines[i]?.trim() !== "") {
689
+ const l = lines[i];
690
+ if (l !== void 0) textLines.push(l.trim());
691
+ i++;
692
+ }
693
+ cues.push({ start, end, text: textLines.join("\n") });
694
+ }
695
+ i++;
696
+ }
697
+ return cues;
698
+ };
699
+ var loadSubtitleFile = async (url) => {
700
+ try {
701
+ const response = await fetch(url);
702
+ const content = await response.text();
703
+ if (content.includes("WEBVTT") || url.endsWith(".vtt")) return parseVTT(content);
704
+ if (url.endsWith(".srt")) return parseSRT(content);
705
+ if (content.includes("-->")) return content.includes(",") ? parseSRT(content) : parseVTT(content);
706
+ return [];
707
+ } catch {
708
+ return [];
709
+ }
710
+ };
711
+ var findActiveCue = (cues, currentTime) => cues.find((c) => currentTime >= c.start && currentTime <= c.end) ?? null;
712
+ var DEFAULT_FILTERS = {
713
+ brightness: 100,
714
+ contrast: 100,
715
+ saturation: 100,
716
+ hue: 0,
717
+ blur: 0,
718
+ grayscale: 0
719
+ };
720
+ var useVideoFilters = (videoRef) => {
721
+ const [filters, setFilters] = react.useState(DEFAULT_FILTERS);
722
+ react.useEffect(() => {
723
+ const video = videoRef.current;
724
+ if (!video) return;
725
+ video.style.filter = [
726
+ `brightness(${filters.brightness}%)`,
727
+ `contrast(${filters.contrast}%)`,
728
+ `saturate(${filters.saturation}%)`,
729
+ `hue-rotate(${filters.hue}deg)`,
730
+ `blur(${filters.blur}px)`,
731
+ `grayscale(${filters.grayscale}%)`
732
+ ].join(" ");
733
+ }, [filters, videoRef]);
734
+ const updateFilter = (key, value) => {
735
+ setFilters((prev) => ({ ...prev, [key]: value }));
736
+ };
737
+ const resetFilters = () => setFilters(DEFAULT_FILTERS);
738
+ return { filters, updateFilter, resetFilters };
739
+ };
740
+ var usePlaylist = (initialItems = []) => {
741
+ const [playlist, setPlaylist] = react.useState({
742
+ items: initialItems,
743
+ currentIndex: 0,
744
+ shuffle: false,
745
+ repeat: "none"
746
+ });
747
+ const currentItem = playlist.items[playlist.currentIndex] ?? null;
748
+ const playNext = react.useCallback(() => {
749
+ setPlaylist((prev) => {
750
+ if (prev.items.length === 0) return prev;
751
+ let nextIndex;
752
+ if (prev.shuffle) {
753
+ nextIndex = Math.floor(Math.random() * prev.items.length);
754
+ } else {
755
+ nextIndex = prev.currentIndex + 1;
756
+ if (nextIndex >= prev.items.length) {
757
+ nextIndex = prev.repeat === "all" ? 0 : prev.currentIndex;
758
+ }
759
+ }
760
+ return { ...prev, currentIndex: nextIndex };
761
+ });
762
+ }, []);
763
+ const playPrevious = react.useCallback(() => {
764
+ setPlaylist((prev) => {
765
+ if (prev.items.length === 0) return prev;
766
+ let prevIndex;
767
+ if (prev.shuffle) {
768
+ prevIndex = Math.floor(Math.random() * prev.items.length);
769
+ } else {
770
+ prevIndex = prev.currentIndex - 1;
771
+ if (prevIndex < 0) {
772
+ prevIndex = prev.repeat === "all" ? prev.items.length - 1 : 0;
773
+ }
774
+ }
775
+ return { ...prev, currentIndex: prevIndex };
776
+ });
777
+ }, []);
778
+ const playIndex = react.useCallback((index) => {
779
+ setPlaylist((prev) => {
780
+ if (index < 0 || index >= prev.items.length) return prev;
781
+ return { ...prev, currentIndex: index };
782
+ });
783
+ }, []);
784
+ const toggleShuffle = react.useCallback(() => {
785
+ setPlaylist((prev) => ({ ...prev, shuffle: !prev.shuffle }));
786
+ }, []);
787
+ const toggleRepeat = react.useCallback(() => {
788
+ setPlaylist((prev) => {
789
+ const modes = ["none", "one", "all"];
790
+ const nextMode = modes[(modes.indexOf(prev.repeat) + 1) % modes.length] ?? "none";
791
+ return { ...prev, repeat: nextMode };
792
+ });
793
+ }, []);
794
+ const addToPlaylist = react.useCallback((item) => {
795
+ setPlaylist((prev) => ({ ...prev, items: [...prev.items, item] }));
796
+ }, []);
797
+ const removeFromPlaylist = react.useCallback((index) => {
798
+ setPlaylist((prev) => {
799
+ const newItems = prev.items.filter((_, i) => i !== index);
800
+ let newIndex = prev.currentIndex;
801
+ if (index < prev.currentIndex) {
802
+ newIndex = prev.currentIndex - 1;
803
+ } else if (index === prev.currentIndex && newItems.length > 0) {
804
+ newIndex = Math.min(prev.currentIndex, newItems.length - 1);
805
+ }
806
+ return { ...prev, items: newItems, currentIndex: Math.max(0, newIndex) };
807
+ });
808
+ }, []);
809
+ return {
810
+ playlist,
811
+ currentItem,
812
+ playNext,
813
+ playPrevious,
814
+ playIndex,
815
+ toggleShuffle,
816
+ toggleRepeat,
817
+ addToPlaylist,
818
+ removeFromPlaylist
819
+ };
820
+ };
821
+ var useQuality = (videoRef, src) => {
822
+ const [qualities, setQualities] = react.useState([]);
823
+ const [currentQuality, setCurrentQuality] = react.useState(-1);
824
+ const [isHls, setIsHls] = react.useState(false);
825
+ const hlsRef = react.useRef(null);
826
+ react.useEffect(() => {
827
+ const video = videoRef.current;
828
+ if (!video) return void 0;
829
+ const isHlsSource = src.endsWith(".m3u8");
830
+ setIsHls(isHlsSource);
831
+ if (isHlsSource && Hls__default.default.isSupported()) {
832
+ const hls = new Hls__default.default({ enableWorker: true, lowLatencyMode: true });
833
+ hlsRef.current = hls;
834
+ hls.loadSource(src);
835
+ hls.attachMedia(video);
836
+ hls.on(Hls__default.default.Events.MANIFEST_PARSED, () => {
837
+ setQualities(
838
+ hls.levels.map((level) => ({
839
+ height: level.height,
840
+ width: level.width,
841
+ bitrate: level.bitrate,
842
+ label: `${level.height}p`
843
+ }))
844
+ );
845
+ });
846
+ hls.on(Hls__default.default.Events.LEVEL_SWITCHED, (_, data) => setCurrentQuality(data.level));
847
+ return () => {
848
+ hls.destroy();
849
+ hlsRef.current = null;
850
+ };
851
+ } else if (isHlsSource && video.canPlayType("application/vnd.apple.mpegurl")) {
852
+ setQualities((prev) => prev.length === 1 && prev[0]?.label === "Auto" ? prev : [{ height: 0, width: 0, bitrate: 0, label: "Auto" }]);
853
+ return void 0;
854
+ } else {
855
+ setQualities((prev) => prev.length === 0 ? prev : []);
856
+ return void 0;
857
+ }
858
+ }, [src, videoRef]);
859
+ const changeQuality = (level) => {
860
+ if (hlsRef.current) {
861
+ hlsRef.current.currentLevel = level;
862
+ setCurrentQuality(level);
863
+ }
864
+ };
865
+ return { qualities, currentQuality, changeQuality, isHls };
866
+ };
867
+ var useChapters = (initialChapters = []) => {
868
+ const [chapters] = react.useState(initialChapters);
869
+ const getCurrentChapter = react.useCallback(
870
+ (currentTime) => chapters.find((c) => currentTime >= c.startTime && currentTime < c.endTime) ?? null,
871
+ [chapters]
872
+ );
873
+ const getChapterAtPosition = react.useCallback(
874
+ (position, duration) => getCurrentChapter(position / 100 * duration),
875
+ [getCurrentChapter]
876
+ );
877
+ return { chapters, getCurrentChapter, getChapterAtPosition };
878
+ };
879
+ var formatDuration = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
880
+ var PlaylistPanel = ({
881
+ items,
882
+ currentIndex,
883
+ shuffle,
884
+ repeat,
885
+ onPlayItem,
886
+ onRemoveItem,
887
+ onToggleShuffle,
888
+ onToggleRepeat,
889
+ onClose
890
+ }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute top-0 right-0 w-full md:w-[280px] h-full bg-popover border-l border-border z-20 flex flex-col", children: [
891
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0", children: [
892
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[13px] font-semibold text-fg", children: [
893
+ "Playlist (",
894
+ items.length,
895
+ ")"
896
+ ] }),
897
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5", children: [
898
+ /* @__PURE__ */ jsxRuntime.jsx(
899
+ styled.Button,
900
+ {
901
+ variant: "ghost",
902
+ size: "icon-sm",
903
+ onClick: onToggleShuffle,
904
+ title: "Shuffle",
905
+ className: shuffle ? "text-primary" : "",
906
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Shuffle, {})
907
+ }
908
+ ),
909
+ /* @__PURE__ */ jsxRuntime.jsx(
910
+ styled.Button,
911
+ {
912
+ variant: "ghost",
913
+ size: "icon-sm",
914
+ onClick: onToggleRepeat,
915
+ title: "Repeat",
916
+ className: repeat !== "none" ? "text-primary" : "",
917
+ children: repeat === "one" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Repeat1, {}) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Repeat, {})
918
+ }
919
+ ),
920
+ /* @__PURE__ */ jsxRuntime.jsx(styled.Button, { variant: "ghost", size: "icon-sm", onClick: onClose, title: "Close", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {}) })
921
+ ] })
922
+ ] }),
923
+ /* @__PURE__ */ jsxRuntime.jsx(styled.ScrollArea.Root, { className: "flex-1 overflow-y-auto p-1.5", children: items.map((item, index) => /* @__PURE__ */ jsxRuntime.jsxs(
924
+ "div",
925
+ {
926
+ className: `flex items-center gap-2.5 p-2 rounded-lg cursor-pointer transition-colors duration-150 relative group hover:bg-accent/[0.07] ${index === currentIndex ? "bg-primary/15" : ""}`,
927
+ onClick: () => onPlayItem(index),
928
+ children: [
929
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "shrink-0 w-10 h-10 bg-muted/80 rounded overflow-hidden flex items-center justify-center text-xs text-muted-foreground", children: item.poster ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: item.poster, alt: item.title, className: "w-full h-full object-cover" }) : /* @__PURE__ */ jsxRuntime.jsx("span", { children: index + 1 }) }),
930
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [
931
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-xs font-medium text-fg overflow-hidden text-ellipsis whitespace-nowrap", children: item.title }),
932
+ item.duration != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-[11px] text-muted-foreground mt-0.5", children: formatDuration(item.duration) })
933
+ ] }),
934
+ /* @__PURE__ */ jsxRuntime.jsx(
935
+ styled.Button,
936
+ {
937
+ variant: "ghost",
938
+ size: "icon-sm",
939
+ className: "opacity-0 transition-opacity duration-150 group-hover:opacity-100",
940
+ onClick: (e) => {
941
+ e.stopPropagation();
942
+ onRemoveItem(index);
943
+ },
944
+ title: "Remove",
945
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, {})
946
+ }
947
+ )
948
+ ]
949
+ },
950
+ item.id
951
+ )) })
952
+ ] });
953
+ PlaylistPanel.displayName = "PlaylistPanel";
954
+ var formatTime2 = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
955
+ var ThumbnailPreview = ({
956
+ videoRef,
957
+ time,
958
+ position
959
+ }) => {
960
+ const canvasRef = react.useRef(null);
961
+ const captureRef = react.useRef(null);
962
+ const [thumbnail, setThumbnail] = react.useState(null);
963
+ react.useEffect(() => {
964
+ const video = videoRef.current;
965
+ if (!video || !canvasRef.current) return;
966
+ if (!captureRef.current) {
967
+ captureRef.current = document.createElement("video");
968
+ captureRef.current.src = video.src;
969
+ captureRef.current.crossOrigin = "anonymous";
970
+ captureRef.current.muted = true;
971
+ }
972
+ const captureVideo = captureRef.current;
973
+ const canvas = canvasRef.current;
974
+ const ctx = canvas.getContext("2d");
975
+ if (!ctx) return;
976
+ captureVideo.currentTime = time;
977
+ const handleSeeked = () => {
978
+ try {
979
+ canvas.width = 160;
980
+ canvas.height = 90;
981
+ ctx.drawImage(captureVideo, 0, 0, 160, 90);
982
+ setThumbnail(canvas.toDataURL());
983
+ } catch {
984
+ }
985
+ };
986
+ captureVideo.addEventListener("seeked", handleSeeked);
987
+ return () => captureVideo.removeEventListener("seeked", handleSeeked);
988
+ }, [time, videoRef]);
989
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute bottom-[calc(100%+8px)] -translate-x-1/2 bg-card border border-border rounded-lg overflow-hidden shadow-lg z-[1001] pointer-events-none", style: { left: `${position}%` }, children: [
990
+ /* @__PURE__ */ jsxRuntime.jsx("canvas", { ref: canvasRef, className: "hidden" }),
991
+ thumbnail ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: thumbnail, alt: "Preview", className: "w-[160px] h-auto block" }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-[160px] h-[90px] flex items-center justify-center bg-muted text-[11px] text-muted-foreground", children: "Loading\u2026" }),
992
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-[11px] text-fg px-2 py-[3px] text-center bg-muted", children: formatTime2(time) })
993
+ ] });
994
+ };
995
+ ThumbnailPreview.displayName = "ThumbnailPreview";
996
+ function extractYouTubeId(url) {
997
+ const match = url.match(
998
+ /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/
999
+ );
1000
+ return match?.[1] ?? null;
1001
+ }
1002
+ var VideoPlayer = ({
1003
+ src,
1004
+ poster,
1005
+ autoPlay = false,
1006
+ loop = false,
1007
+ muted = false,
1008
+ className = "",
1009
+ subtitles = [],
1010
+ playlist: initialPlaylist = [],
1011
+ chapters: initialChapters = [],
1012
+ onPlay,
1013
+ onPause,
1014
+ onEnded,
1015
+ onTimeUpdate,
1016
+ onVolumeChange
1017
+ }) => {
1018
+ const videoRef = react.useRef(null);
1019
+ const containerRef = react.useRef(null);
1020
+ const [showControls, setShowControls] = react.useState(true);
1021
+ const [showPlaylist, setShowPlaylist] = react.useState(false);
1022
+ const [showThumbnail, setShowThumbnail] = react.useState(false);
1023
+ const [videoError, setVideoError] = react.useState(null);
1024
+ const [thumbnailTime, setThumbnailTime] = react.useState(0);
1025
+ const [thumbnailPosition, setThumbnailPosition] = react.useState(0);
1026
+ const controlsTimeoutRef = react.useRef(void 0);
1027
+ const [subtitleTracks, setSubtitleTracks] = react.useState(/* @__PURE__ */ new Map());
1028
+ const [activeSubtitleIndex, setActiveSubtitleIndex] = react.useState(-1);
1029
+ const [subtitlesEnabled, setSubtitlesEnabled] = react.useState(false);
1030
+ const [currentSubtitleText, setCurrentSubtitleText] = react.useState("");
1031
+ const [subtitleStyle, setSubtitleStyle] = react.useState({
1032
+ fontSize: 20,
1033
+ fontFamily: "Arial, sans-serif",
1034
+ textColor: "#ffffff",
1035
+ backgroundColor: "#000000",
1036
+ backgroundOpacity: 0.75,
1037
+ position: "bottom"
1038
+ });
1039
+ const [availableSubtitles, setAvailableSubtitles] = react.useState(subtitles);
1040
+ const { filters, updateFilter, resetFilters } = useVideoFilters(videoRef);
1041
+ const {
1042
+ playlist,
1043
+ currentItem,
1044
+ playNext,
1045
+ playPrevious,
1046
+ playIndex,
1047
+ toggleShuffle,
1048
+ toggleRepeat,
1049
+ removeFromPlaylist
1050
+ } = usePlaylist(
1051
+ initialPlaylist.length > 0 ? initialPlaylist : [{ id: "1", title: "Current Video", src, poster }]
1052
+ );
1053
+ const currentSrc = currentItem?.src ?? src;
1054
+ const { qualities, currentQuality, changeQuality, isHls } = useQuality(videoRef, currentSrc);
1055
+ const { chapters } = useChapters(initialChapters);
1056
+ const {
1057
+ isPlaying,
1058
+ currentTime,
1059
+ duration,
1060
+ volume,
1061
+ isMuted,
1062
+ playbackRate,
1063
+ isFullscreen,
1064
+ isPiP,
1065
+ buffered,
1066
+ togglePlay,
1067
+ handleSeek,
1068
+ handleVolumeChange,
1069
+ toggleMute,
1070
+ changePlaybackRate,
1071
+ toggleFullscreen,
1072
+ togglePiP,
1073
+ skipForward,
1074
+ skipBackward
1075
+ } = useVideoPlayer({
1076
+ videoRef,
1077
+ containerRef,
1078
+ autoPlay,
1079
+ muted,
1080
+ onPlay,
1081
+ onPause,
1082
+ onEnded: () => {
1083
+ onEnded?.();
1084
+ if (playlist.repeat === "one") {
1085
+ videoRef.current?.play();
1086
+ } else if (playlist.items.length > 1) {
1087
+ playNext();
1088
+ }
1089
+ },
1090
+ onTimeUpdate,
1091
+ onVolumeChange
1092
+ });
1093
+ react.useEffect(() => {
1094
+ if (availableSubtitles.length === 0) return;
1095
+ const loadSubtitles = async () => {
1096
+ const tracks = /* @__PURE__ */ new Map();
1097
+ for (const track of availableSubtitles) {
1098
+ const cues = await loadSubtitleFile(track.src);
1099
+ tracks.set(track.src, cues);
1100
+ }
1101
+ setSubtitleTracks(tracks);
1102
+ if (activeSubtitleIndex === -1) {
1103
+ setActiveSubtitleIndex(0);
1104
+ setSubtitlesEnabled(true);
1105
+ }
1106
+ };
1107
+ loadSubtitles();
1108
+ }, [availableSubtitles]);
1109
+ const handleSubtitleUpload = async (file) => {
1110
+ const url = URL.createObjectURL(file);
1111
+ const newTrack = {
1112
+ src: url,
1113
+ label: file.name.replace(/\.(srt|vtt)$/, ""),
1114
+ language: "unknown"
1115
+ };
1116
+ setAvailableSubtitles((prev) => [...prev, newTrack]);
1117
+ };
1118
+ react.useEffect(() => {
1119
+ if (!subtitlesEnabled || activeSubtitleIndex === -1 || availableSubtitles.length === 0) {
1120
+ setCurrentSubtitleText("");
1121
+ return;
1122
+ }
1123
+ const activeTrack = availableSubtitles[activeSubtitleIndex];
1124
+ if (!activeTrack) {
1125
+ setCurrentSubtitleText("");
1126
+ return;
1127
+ }
1128
+ const cues = subtitleTracks.get(activeTrack.src);
1129
+ const activeCue = cues ? findActiveCue(cues, currentTime) : null;
1130
+ setCurrentSubtitleText(activeCue?.text ?? "");
1131
+ }, [currentTime, subtitlesEnabled, activeSubtitleIndex, subtitleTracks, availableSubtitles]);
1132
+ react.useEffect(() => {
1133
+ const video = videoRef.current;
1134
+ if (!video || isHls) return;
1135
+ setVideoError(null);
1136
+ video.load();
1137
+ }, [currentSrc, isHls]);
1138
+ react.useEffect(() => {
1139
+ const video = videoRef.current;
1140
+ if (!video) return;
1141
+ const handleError = () => {
1142
+ const code = video.error?.code;
1143
+ if (code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED || code === MediaError.MEDIA_ERR_NETWORK) {
1144
+ setVideoError("Video not found or unsupported format.");
1145
+ } else if (code) {
1146
+ setVideoError("Failed to load video.");
1147
+ }
1148
+ };
1149
+ video.addEventListener("error", handleError);
1150
+ return () => video.removeEventListener("error", handleError);
1151
+ }, [videoRef]);
1152
+ react.useEffect(() => {
1153
+ const video = videoRef.current;
1154
+ if (!video || !currentItem || isHls) return void 0;
1155
+ if (autoPlay || isPlaying) {
1156
+ const onCanPlay = () => {
1157
+ video.play().catch(console.error);
1158
+ };
1159
+ video.addEventListener("canplay", onCanPlay, { once: true });
1160
+ return () => video.removeEventListener("canplay", onCanPlay);
1161
+ }
1162
+ return void 0;
1163
+ }, [currentItem, isHls]);
1164
+ react.useEffect(() => {
1165
+ const handleMouseMove = () => {
1166
+ setShowControls(true);
1167
+ if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
1168
+ if (isPlaying) {
1169
+ controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3e3);
1170
+ }
1171
+ };
1172
+ const handleMouseLeave = () => {
1173
+ if (isPlaying) setShowControls(false);
1174
+ };
1175
+ const container = containerRef.current;
1176
+ container?.addEventListener("mousemove", handleMouseMove);
1177
+ container?.addEventListener("mouseleave", handleMouseLeave);
1178
+ return () => {
1179
+ container?.removeEventListener("mousemove", handleMouseMove);
1180
+ container?.removeEventListener("mouseleave", handleMouseLeave);
1181
+ if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
1182
+ };
1183
+ }, [isPlaying]);
1184
+ react.useEffect(() => {
1185
+ const handleKeyDown = (e) => {
1186
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
1187
+ switch (e.key) {
1188
+ case " ":
1189
+ case "k":
1190
+ e.preventDefault();
1191
+ togglePlay();
1192
+ break;
1193
+ case "ArrowRight":
1194
+ e.preventDefault();
1195
+ skipForward();
1196
+ break;
1197
+ case "ArrowLeft":
1198
+ e.preventDefault();
1199
+ skipBackward();
1200
+ break;
1201
+ case "ArrowUp":
1202
+ e.preventDefault();
1203
+ handleVolumeChange(Math.min(volume + 0.1, 1));
1204
+ break;
1205
+ case "ArrowDown":
1206
+ e.preventDefault();
1207
+ handleVolumeChange(Math.max(volume - 0.1, 0));
1208
+ break;
1209
+ case "m":
1210
+ e.preventDefault();
1211
+ toggleMute();
1212
+ break;
1213
+ case "f":
1214
+ e.preventDefault();
1215
+ toggleFullscreen();
1216
+ break;
1217
+ case "p":
1218
+ e.preventDefault();
1219
+ togglePiP();
1220
+ break;
1221
+ case "c":
1222
+ e.preventDefault();
1223
+ setSubtitlesEnabled((v) => !v);
1224
+ break;
1225
+ case ",":
1226
+ e.preventDefault();
1227
+ if (videoRef.current) videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / 30);
1228
+ break;
1229
+ case ".":
1230
+ e.preventDefault();
1231
+ if (videoRef.current) videoRef.current.currentTime = Math.min(duration, videoRef.current.currentTime + 1 / 30);
1232
+ break;
1233
+ }
1234
+ };
1235
+ window.addEventListener("keydown", handleKeyDown);
1236
+ return () => window.removeEventListener("keydown", handleKeyDown);
1237
+ }, [togglePlay, skipForward, skipBackward, volume, handleVolumeChange, toggleMute, toggleFullscreen, togglePiP, duration]);
1238
+ const youtubeId = extractYouTubeId(currentSrc);
1239
+ if (youtubeId) {
1240
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `relative w-full aspect-video bg-black overflow-hidden rounded-lg shadow-lg ${className}`, children: /* @__PURE__ */ jsxRuntime.jsx(
1241
+ "iframe",
1242
+ {
1243
+ src: `https://www.youtube.com/embed/${youtubeId}?autoplay=${autoPlay ? 1 : 0}&mute=${muted ? 1 : 0}&loop=${loop ? 1 : 0}&rel=0`,
1244
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
1245
+ allowFullScreen: true,
1246
+ className: "absolute inset-0 w-full h-full border-0",
1247
+ title: "YouTube video player"
1248
+ }
1249
+ ) });
1250
+ }
1251
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1252
+ "div",
1253
+ {
1254
+ ref: containerRef,
1255
+ className: `relative w-full aspect-video bg-bg overflow-hidden rounded-lg shadow-lg font-sans text-sm text-fg select-none ${className}`,
1256
+ children: [
1257
+ /* @__PURE__ */ jsxRuntime.jsx(
1258
+ "video",
1259
+ {
1260
+ ref: videoRef,
1261
+ src: !isHls ? currentSrc : void 0,
1262
+ poster: poster ?? currentItem?.poster,
1263
+ loop,
1264
+ onClick: togglePlay,
1265
+ className: "w-full h-full object-contain block cursor-pointer"
1266
+ }
1267
+ ),
1268
+ subtitlesEnabled && currentSubtitleText && /* @__PURE__ */ jsxRuntime.jsx(SubtitleDisplay, { text: currentSubtitleText, style: subtitleStyle }),
1269
+ videoError ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-bg/90 gap-3", children: [
1270
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "w-12 h-12 text-muted-foreground", fill: "none", stroke: "currentColor", strokeWidth: 1.5, viewBox: "0 0 24 24", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" }) }),
1271
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium text-fg", children: videoError })
1272
+ ] }) : !isPlaying && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-fg/10 cursor-pointer transition-opacity duration-300", onClick: togglePlay, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-[72px] h-[72px] rounded-full bg-primary/90 flex items-center justify-center cursor-pointer transition-all duration-200 hover:bg-primary hover:scale-110", children: /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "w-9 h-9 text-primary-fg fill-current ml-1", fill: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 5v14l11-7z" }) }) }) }),
1273
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `absolute bottom-0 left-0 right-0 transition-opacity duration-300${showControls || !isPlaying ? "" : " opacity-0 pointer-events-none"}`, children: [
1274
+ /* @__PURE__ */ jsxRuntime.jsx(
1275
+ VideoControls,
1276
+ {
1277
+ isPlaying,
1278
+ currentTime,
1279
+ duration,
1280
+ volume,
1281
+ isMuted,
1282
+ playbackRate,
1283
+ isFullscreen,
1284
+ isPiP,
1285
+ buffered,
1286
+ subtitlesEnabled,
1287
+ hasSubtitles: availableSubtitles.length > 0,
1288
+ hasPlaylist: playlist.items.length > 1,
1289
+ chapters,
1290
+ onPlayPause: togglePlay,
1291
+ onSeek: handleSeek,
1292
+ onVolumeChange: handleVolumeChange,
1293
+ onMuteToggle: toggleMute,
1294
+ onPlaybackRateChange: changePlaybackRate,
1295
+ onFullscreenToggle: toggleFullscreen,
1296
+ onPiPToggle: togglePiP,
1297
+ onSubtitlesToggle: () => setSubtitlesEnabled((v) => !v),
1298
+ onPlaylistClick: () => setShowPlaylist((v) => !v),
1299
+ onNext: playNext,
1300
+ onPrevious: playPrevious,
1301
+ settingsPanel: /* @__PURE__ */ jsxRuntime.jsx(
1302
+ VideoSettings,
1303
+ {
1304
+ playbackRate,
1305
+ subtitleStyle,
1306
+ subtitles: availableSubtitles,
1307
+ activeSubtitleIndex,
1308
+ filters,
1309
+ qualities,
1310
+ currentQuality,
1311
+ onPlaybackRateChange: changePlaybackRate,
1312
+ onSubtitleStyleChange: setSubtitleStyle,
1313
+ onSubtitleTrackChange: setActiveSubtitleIndex,
1314
+ onSubtitleUpload: handleSubtitleUpload,
1315
+ onFilterChange: updateFilter,
1316
+ onResetFilters: resetFilters,
1317
+ onQualityChange: changeQuality
1318
+ }
1319
+ ),
1320
+ onThumbnailHover: (show, time, position) => {
1321
+ setShowThumbnail(show);
1322
+ setThumbnailTime(time);
1323
+ setThumbnailPosition(position);
1324
+ }
1325
+ }
1326
+ ),
1327
+ showThumbnail && /* @__PURE__ */ jsxRuntime.jsx(
1328
+ ThumbnailPreview,
1329
+ {
1330
+ videoRef,
1331
+ time: thumbnailTime,
1332
+ position: thumbnailPosition,
1333
+ duration
1334
+ }
1335
+ )
1336
+ ] }),
1337
+ showPlaylist && /* @__PURE__ */ jsxRuntime.jsx(
1338
+ PlaylistPanel,
1339
+ {
1340
+ items: playlist.items,
1341
+ currentIndex: playlist.currentIndex,
1342
+ shuffle: playlist.shuffle,
1343
+ repeat: playlist.repeat,
1344
+ onPlayItem: playIndex,
1345
+ onRemoveItem: removeFromPlaylist,
1346
+ onToggleShuffle: toggleShuffle,
1347
+ onToggleRepeat: toggleRepeat,
1348
+ onClose: () => setShowPlaylist(false)
1349
+ }
1350
+ )
1351
+ ]
1352
+ }
1353
+ );
1354
+ };
1355
+ VideoPlayer.displayName = "VideoPlayer";
1356
+
1357
+ exports.VideoPlayer = VideoPlayer;
1358
+ //# sourceMappingURL=index.cjs.map
1359
+ //# sourceMappingURL=index.cjs.map