@lightbird/core 0.1.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.
@@ -0,0 +1,915 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+
5
+ // src/react/use-video-playback.ts
6
+ function useVideoPlayback(videoRef) {
7
+ const [isPlaying, setIsPlaying] = react.useState(false);
8
+ const [progress, setProgress] = react.useState(0);
9
+ const [duration, setDuration] = react.useState(0);
10
+ const [volume, setVolumeState] = react.useState(1);
11
+ const [isMuted, setIsMuted] = react.useState(false);
12
+ const [playbackRate, setPlaybackRateState] = react.useState(1);
13
+ const [loop, setLoop] = react.useState(false);
14
+ react.useEffect(() => {
15
+ const el = videoRef.current;
16
+ if (!el) return;
17
+ setIsPlaying(!el.paused);
18
+ setProgress(el.currentTime);
19
+ if (el.duration && !Number.isNaN(el.duration)) setDuration(el.duration);
20
+ setVolumeState(el.volume);
21
+ setIsMuted(el.muted);
22
+ const onPlay = () => setIsPlaying(true);
23
+ const onPause = () => setIsPlaying(false);
24
+ const onTimeUpdate = () => setProgress(el.currentTime);
25
+ const onLoadedMetadata = () => setDuration(el.duration);
26
+ const onVolumeChange = () => {
27
+ setVolumeState(el.volume);
28
+ setIsMuted(el.muted);
29
+ };
30
+ el.addEventListener("play", onPlay);
31
+ el.addEventListener("pause", onPause);
32
+ el.addEventListener("timeupdate", onTimeUpdate);
33
+ el.addEventListener("loadedmetadata", onLoadedMetadata);
34
+ el.addEventListener("volumechange", onVolumeChange);
35
+ return () => {
36
+ el.removeEventListener("play", onPlay);
37
+ el.removeEventListener("pause", onPause);
38
+ el.removeEventListener("timeupdate", onTimeUpdate);
39
+ el.removeEventListener("loadedmetadata", onLoadedMetadata);
40
+ el.removeEventListener("volumechange", onVolumeChange);
41
+ };
42
+ }, [videoRef]);
43
+ react.useEffect(() => {
44
+ const el = videoRef.current;
45
+ if (el) el.loop = loop;
46
+ }, [loop, videoRef]);
47
+ const togglePlay = react.useCallback(() => {
48
+ const el = videoRef.current;
49
+ if (!el) return;
50
+ if (el.paused) {
51
+ el.play().catch(() => {
52
+ });
53
+ } else {
54
+ el.pause();
55
+ }
56
+ }, [videoRef]);
57
+ const seek = react.useCallback(
58
+ (t) => {
59
+ const el = videoRef.current;
60
+ if (!el) return;
61
+ el.currentTime = Math.max(0, Math.min(t, el.duration || 0));
62
+ },
63
+ [videoRef]
64
+ );
65
+ const setVolume = react.useCallback(
66
+ (v) => {
67
+ const el = videoRef.current;
68
+ if (!el) return;
69
+ el.volume = v;
70
+ el.muted = v === 0;
71
+ },
72
+ [videoRef]
73
+ );
74
+ const toggleMute = react.useCallback(() => {
75
+ const el = videoRef.current;
76
+ if (!el) return;
77
+ el.muted = !el.muted;
78
+ }, [videoRef]);
79
+ const setPlaybackRate = react.useCallback(
80
+ (r) => {
81
+ const el = videoRef.current;
82
+ if (!el) return;
83
+ el.playbackRate = r;
84
+ setPlaybackRateState(r);
85
+ },
86
+ [videoRef]
87
+ );
88
+ const frameStep = react.useCallback(
89
+ (direction) => {
90
+ const el = videoRef.current;
91
+ if (!el) return;
92
+ el.pause();
93
+ const frameTime = 1 / 30;
94
+ const delta = direction === "forward" ? frameTime : -frameTime;
95
+ el.currentTime = Math.max(0, Math.min(el.currentTime + delta, el.duration || 0));
96
+ },
97
+ [videoRef]
98
+ );
99
+ const toggleLoop = react.useCallback(() => setLoop((l) => !l), []);
100
+ return {
101
+ isPlaying,
102
+ progress,
103
+ duration,
104
+ volume,
105
+ isMuted,
106
+ playbackRate,
107
+ loop,
108
+ togglePlay,
109
+ seek,
110
+ setVolume,
111
+ toggleMute,
112
+ setPlaybackRate,
113
+ frameStep,
114
+ toggleLoop
115
+ };
116
+ }
117
+ var DEFAULT_FILTERS = {
118
+ brightness: 100,
119
+ contrast: 100,
120
+ saturate: 100,
121
+ hue: 0
122
+ };
123
+ function useVideoFilters(videoRef) {
124
+ const [filters, setFilters] = react.useState(DEFAULT_FILTERS);
125
+ const [zoom, setZoom] = react.useState(1);
126
+ const rafRef = react.useRef(null);
127
+ react.useEffect(() => {
128
+ const el = videoRef.current;
129
+ if (!el) return;
130
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
131
+ rafRef.current = requestAnimationFrame(() => {
132
+ el.style.filter = `brightness(${filters.brightness}%) contrast(${filters.contrast}%) saturate(${filters.saturate}%) hue-rotate(${filters.hue}deg)`;
133
+ el.style.transform = `scale(${zoom})`;
134
+ rafRef.current = null;
135
+ });
136
+ return () => {
137
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
138
+ };
139
+ }, [filters, zoom, videoRef]);
140
+ const resetFilters = () => {
141
+ setFilters(DEFAULT_FILTERS);
142
+ setZoom(1);
143
+ };
144
+ return { filters, zoom, setFilters, setZoom, resetFilters };
145
+ }
146
+
147
+ // src/subtitles/subtitle-converter.ts
148
+ var SubtitleConverter = class {
149
+ static async convertSrtToVtt(srtContent) {
150
+ let vttContent = "WEBVTT\n\n";
151
+ const blocks = srtContent.split(/\n\s*\n/);
152
+ for (const block of blocks) {
153
+ const lines = block.trim().split("\n");
154
+ if (lines.length >= 3) {
155
+ const timecodeLine = lines[1];
156
+ const textLines = lines.slice(2);
157
+ const vttTimecode = timecodeLine.replace(/,/g, ".");
158
+ vttContent += `${vttTimecode}
159
+ `;
160
+ vttContent += `${textLines.join("\n")}
161
+
162
+ `;
163
+ }
164
+ }
165
+ return vttContent;
166
+ }
167
+ static async convertFileToVtt(file) {
168
+ const fileName = file.name.toLowerCase();
169
+ if (fileName.endsWith(".vtt")) {
170
+ return file;
171
+ }
172
+ if (fileName.endsWith(".srt")) {
173
+ const srtContent = await file.text();
174
+ const vttContent = await this.convertSrtToVtt(srtContent);
175
+ const vttBlob = new Blob([vttContent], { type: "text/vtt" });
176
+ const vttFileName = file.name.replace(/\.srt$/i, ".vtt");
177
+ return new File([vttBlob], vttFileName, { type: "text/vtt" });
178
+ }
179
+ return file;
180
+ }
181
+ };
182
+
183
+ // src/subtitles/subtitle-offset.ts
184
+ function shiftTimestamp(ts, delta) {
185
+ ts.split(":").length === 3 ? [ts.slice(0, 5), ts.slice(6)] : ["00:00", ts.slice(3)];
186
+ const parts = ts.split(":");
187
+ const h = Number(parts[0]);
188
+ const m = Number(parts[1]);
189
+ const s = Number(parts[2]);
190
+ const total = Math.max(0, h * 3600 + m * 60 + s + delta);
191
+ const hh = Math.floor(total / 3600);
192
+ const mm = Math.floor(total % 3600 / 60);
193
+ const ss = total % 60;
194
+ const ssStr = ss.toFixed(3).padStart(6, "0");
195
+ return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${ssStr}`;
196
+ }
197
+ function applyOffsetToVtt(vttText, offsetSeconds) {
198
+ if (offsetSeconds === 0) return vttText;
199
+ return vttText.replace(
200
+ /(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})/g,
201
+ (_, start, end) => {
202
+ const shiftedStart = shiftTimestamp(start, offsetSeconds);
203
+ const shiftedEnd = shiftTimestamp(end, offsetSeconds);
204
+ return `${shiftedStart} --> ${shiftedEnd}`;
205
+ }
206
+ );
207
+ }
208
+
209
+ // src/subtitles/subtitle-manager.ts
210
+ function detectEncoding(bytes) {
211
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
212
+ return "UTF-8";
213
+ }
214
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) {
215
+ return "UTF-16LE";
216
+ }
217
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) {
218
+ return "UTF-16BE";
219
+ }
220
+ return "UTF-8";
221
+ }
222
+ async function readSubtitleFile(file) {
223
+ const buffer = await file.arrayBuffer();
224
+ const bytes = new Uint8Array(buffer);
225
+ const detected = detectEncoding(bytes);
226
+ const decoder = new TextDecoder(detected);
227
+ return decoder.decode(buffer);
228
+ }
229
+ function parseVttCues(vttText) {
230
+ const cues = [];
231
+ const cueRegex = /(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})[^\n]*\n([\s\S]*?)(?=\n\n|\n*$)/g;
232
+ let match;
233
+ while ((match = cueRegex.exec(vttText)) !== null) {
234
+ const startTime = timestampToSeconds(match[1]);
235
+ const endTime = timestampToSeconds(match[2]);
236
+ const text = match[3].trim().replace(/<[^>]+>/g, "");
237
+ if (text) cues.push({ startTime, endTime, text });
238
+ }
239
+ return cues;
240
+ }
241
+ function timestampToSeconds(ts) {
242
+ const parts = ts.split(":");
243
+ const h = Number(parts[0]);
244
+ const m = Number(parts[1]);
245
+ const s = Number(parts[2]);
246
+ return h * 3600 + m * 60 + s;
247
+ }
248
+ var UniversalSubtitleManager = class {
249
+ constructor(videoElement) {
250
+ this.records = [];
251
+ this.videoElement = null;
252
+ this.nextId = 0;
253
+ this.videoElement = videoElement || null;
254
+ }
255
+ setVideoElement(videoElement) {
256
+ this.videoElement = videoElement;
257
+ }
258
+ async addSubtitleFiles(files) {
259
+ const newSubtitles = [];
260
+ for (const file of files) {
261
+ const ext = file.name.split(".").pop()?.toLowerCase();
262
+ const langMatch = file.name.match(/\.([a-z]{2,3})\.(?:srt|vtt|ass|ssa)$/i);
263
+ const lang = langMatch ? langMatch[1] : "unknown";
264
+ const subtitle = {
265
+ id: String(this.nextId++),
266
+ name: `${lang.toUpperCase()} (${file.name})`,
267
+ lang,
268
+ type: "external",
269
+ format: ext ?? "vtt"
270
+ };
271
+ let rawVtt;
272
+ let cues = [];
273
+ if (ext === "ass" || ext === "ssa") {
274
+ const rawText = await readSubtitleFile(file);
275
+ subtitle.url = void 0;
276
+ const blob = new Blob([rawText], { type: "text/plain" });
277
+ subtitle.url = URL.createObjectURL(blob);
278
+ rawVtt = void 0;
279
+ } else {
280
+ const fileText = await readSubtitleFile(file);
281
+ let vttText;
282
+ if (ext === "srt") {
283
+ vttText = await SubtitleConverter.convertSrtToVtt(fileText);
284
+ } else {
285
+ vttText = fileText;
286
+ }
287
+ rawVtt = vttText;
288
+ cues = parseVttCues(vttText);
289
+ const blob = new Blob([vttText], { type: "text/vtt" });
290
+ subtitle.url = URL.createObjectURL(blob);
291
+ }
292
+ newSubtitles.push(subtitle);
293
+ this.records.push({ subtitle, rawVtt, offset: 0, cues });
294
+ if (this.videoElement && ext !== "ass" && ext !== "ssa" && subtitle.url) {
295
+ const track = document.createElement("track");
296
+ track.kind = "subtitles";
297
+ track.label = subtitle.name;
298
+ track.srclang = subtitle.lang;
299
+ track.src = subtitle.url;
300
+ track.setAttribute("data-id", subtitle.id);
301
+ track.default = false;
302
+ track.addEventListener("load", () => {
303
+ console.log(`Subtitle track loaded: ${subtitle.name}`);
304
+ });
305
+ track.addEventListener("error", (e) => {
306
+ console.error(`Failed to load subtitle track: ${subtitle.name}`, e);
307
+ });
308
+ this.videoElement.appendChild(track);
309
+ const textTrack = track.track;
310
+ textTrack.mode = "hidden";
311
+ setTimeout(() => {
312
+ textTrack.mode = "disabled";
313
+ }, 100);
314
+ }
315
+ }
316
+ return newSubtitles;
317
+ }
318
+ removeSubtitle(id) {
319
+ const index = this.records.findIndex((r) => r.subtitle.id === id);
320
+ if (index === -1) return false;
321
+ const { subtitle } = this.records[index];
322
+ if (this.videoElement) {
323
+ const tracks = this.videoElement.querySelectorAll("track");
324
+ for (const track of tracks) {
325
+ if (track.getAttribute("data-id") === id) {
326
+ track.remove();
327
+ break;
328
+ }
329
+ }
330
+ }
331
+ if (subtitle.url && subtitle.url.startsWith("blob:")) {
332
+ URL.revokeObjectURL(subtitle.url);
333
+ }
334
+ this.records.splice(index, 1);
335
+ return true;
336
+ }
337
+ switchSubtitle(id) {
338
+ if (!this.videoElement) return;
339
+ const tracks = this.videoElement.textTracks;
340
+ for (let i = 0; i < tracks.length; i++) {
341
+ tracks[i].mode = "disabled";
342
+ }
343
+ if (id === "-1") return;
344
+ const trackElements = this.videoElement.querySelectorAll("track");
345
+ for (let i = 0; i < trackElements.length; i++) {
346
+ const trackElement = trackElements[i];
347
+ if (trackElement.getAttribute("data-id") === id) {
348
+ const textTrack = trackElement.track;
349
+ if (textTrack) {
350
+ if (trackElement.readyState === 2) {
351
+ textTrack.mode = "hidden";
352
+ } else {
353
+ const onLoad = () => {
354
+ textTrack.mode = "hidden";
355
+ trackElement.removeEventListener("load", onLoad);
356
+ };
357
+ trackElement.addEventListener("load", onLoad);
358
+ textTrack.mode = "hidden";
359
+ }
360
+ }
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ /**
366
+ * Sets a time offset (in seconds) for a VTT/SRT subtitle.
367
+ * Regenerates the blob URL with shifted timestamps and updates the track element.
368
+ */
369
+ async setOffset(id, offsetSeconds) {
370
+ const record = this.records.find((r) => r.subtitle.id === id);
371
+ if (!record || record.rawVtt === void 0) return;
372
+ if (record.offset === offsetSeconds) return;
373
+ record.offset = offsetSeconds;
374
+ if (record.subtitle.url && record.subtitle.url.startsWith("blob:")) {
375
+ URL.revokeObjectURL(record.subtitle.url);
376
+ }
377
+ const shifted = applyOffsetToVtt(record.rawVtt, offsetSeconds);
378
+ const blob = new Blob([shifted], { type: "text/vtt" });
379
+ const newUrl = URL.createObjectURL(blob);
380
+ record.subtitle.url = newUrl;
381
+ if (this.videoElement) {
382
+ const trackElements = this.videoElement.querySelectorAll("track");
383
+ for (const trackEl of trackElements) {
384
+ if (trackEl.getAttribute("data-id") === id) {
385
+ trackEl.src = newUrl;
386
+ break;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ /** Returns parsed cue index for a subtitle (empty array for ASS/SSA). */
392
+ getCues(id) {
393
+ return this.records.find((r) => r.subtitle.id === id)?.cues ?? [];
394
+ }
395
+ /** Returns cues for all loaded VTT/SRT subtitles merged, for global search. */
396
+ getAllCues() {
397
+ return this.records.flatMap((r) => r.cues);
398
+ }
399
+ getSubtitles() {
400
+ return this.records.map((r) => r.subtitle);
401
+ }
402
+ clearSubtitles() {
403
+ for (const { subtitle } of this.records) {
404
+ if (subtitle.url && subtitle.url.startsWith("blob:")) {
405
+ URL.revokeObjectURL(subtitle.url);
406
+ }
407
+ }
408
+ if (this.videoElement) {
409
+ const tracks = this.videoElement.querySelectorAll("track");
410
+ tracks.forEach((track) => track.remove());
411
+ }
412
+ this.records = [];
413
+ }
414
+ destroy() {
415
+ this.clearSubtitles();
416
+ this.videoElement = null;
417
+ }
418
+ importSubtitles(subtitles) {
419
+ this.records = subtitles.map((s) => ({
420
+ subtitle: s,
421
+ rawVtt: void 0,
422
+ offset: 0,
423
+ cues: []
424
+ }));
425
+ this.nextId = Math.max(...subtitles.map((s) => parseInt(s.id)), 0) + 1;
426
+ }
427
+ };
428
+
429
+ // src/react/use-subtitles.ts
430
+ function useSubtitles(options) {
431
+ const [subtitles, setSubtitles] = react.useState([]);
432
+ const [activeSubtitle, setActiveSubtitle] = react.useState("-1");
433
+ const managerRef = react.useRef(null);
434
+ react.useEffect(() => {
435
+ return () => {
436
+ managerRef.current?.destroy();
437
+ managerRef.current = null;
438
+ };
439
+ }, []);
440
+ const initManager = react.useCallback((videoEl) => {
441
+ managerRef.current?.destroy();
442
+ managerRef.current = new UniversalSubtitleManager(videoEl);
443
+ }, []);
444
+ const importSubtitles = react.useCallback((subs) => {
445
+ if (!managerRef.current) return;
446
+ managerRef.current.importSubtitles(subs);
447
+ setSubtitles(managerRef.current.getSubtitles());
448
+ }, []);
449
+ const reset = react.useCallback(() => {
450
+ managerRef.current?.destroy();
451
+ managerRef.current = null;
452
+ setSubtitles([]);
453
+ setActiveSubtitle("-1");
454
+ }, []);
455
+ const addSubtitleFiles = react.useCallback(
456
+ async (files) => {
457
+ if (!managerRef.current) return;
458
+ try {
459
+ await managerRef.current.addSubtitleFiles(files);
460
+ setSubtitles(managerRef.current.getSubtitles());
461
+ options?.onSuccess?.(`Added ${files.length} subtitle file(s).`);
462
+ } catch (error) {
463
+ console.error("Failed to add subtitles:", error);
464
+ options?.onError?.("Failed to add subtitles");
465
+ }
466
+ },
467
+ [options]
468
+ );
469
+ const removeSubtitle = react.useCallback(
470
+ (id) => {
471
+ if (!managerRef.current) return;
472
+ const success = managerRef.current.removeSubtitle(id);
473
+ if (success) {
474
+ setSubtitles(managerRef.current.getSubtitles());
475
+ if (activeSubtitle === id) {
476
+ setActiveSubtitle("-1");
477
+ managerRef.current.switchSubtitle("-1");
478
+ }
479
+ options?.onSuccess?.("Subtitle removed");
480
+ }
481
+ },
482
+ [activeSubtitle, options]
483
+ );
484
+ const switchSubtitle = react.useCallback((id) => {
485
+ setActiveSubtitle(id);
486
+ managerRef.current?.switchSubtitle(id);
487
+ }, []);
488
+ return {
489
+ subtitles,
490
+ activeSubtitle,
491
+ managerRef,
492
+ initManager,
493
+ importSubtitles,
494
+ reset,
495
+ addSubtitleFiles,
496
+ removeSubtitle,
497
+ switchSubtitle
498
+ };
499
+ }
500
+ var VIDEO_EXTENSIONS = [".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mp4", ".m4v"];
501
+ var SUBTITLE_EXTENSIONS = [".srt", ".vtt", ".ass", ".ssa"];
502
+ var STORAGE_KEY = "lightbird-playlist";
503
+ async function getFileDuration(url) {
504
+ return new Promise((resolve) => {
505
+ const el = document.createElement("video");
506
+ el.preload = "metadata";
507
+ el.src = url;
508
+ el.onloadedmetadata = () => resolve(el.duration);
509
+ el.onerror = () => resolve(0);
510
+ });
511
+ }
512
+ function usePlaylist() {
513
+ const [playlist, setPlaylist] = react.useState([]);
514
+ const [currentIndex, setCurrentIndex] = react.useState(null);
515
+ const playlistRef = react.useRef(playlist);
516
+ react.useEffect(() => {
517
+ playlistRef.current = playlist;
518
+ });
519
+ react.useEffect(() => {
520
+ try {
521
+ const saved = localStorage.getItem(STORAGE_KEY);
522
+ if (saved) {
523
+ const items = JSON.parse(saved).filter(
524
+ (i) => i.type === "stream"
525
+ );
526
+ if (items.length > 0) setPlaylist(items);
527
+ }
528
+ } catch {
529
+ }
530
+ }, []);
531
+ react.useEffect(() => {
532
+ try {
533
+ const serializable = playlist.filter((i) => i.type === "stream");
534
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
535
+ } catch {
536
+ }
537
+ }, [playlist]);
538
+ react.useEffect(() => {
539
+ return () => {
540
+ playlistRef.current.forEach((item) => {
541
+ if (item.file) URL.revokeObjectURL(item.url);
542
+ });
543
+ };
544
+ }, []);
545
+ const currentItem = currentIndex !== null ? playlist[currentIndex] ?? null : null;
546
+ const selectItem = react.useCallback((index) => {
547
+ setCurrentIndex(index);
548
+ }, []);
549
+ const appendItem = react.useCallback((item) => {
550
+ setPlaylist((p) => [...p, item]);
551
+ }, []);
552
+ const removeItem = react.useCallback(
553
+ (index) => {
554
+ setPlaylist((prev) => {
555
+ const item = prev[index];
556
+ if (item?.file) URL.revokeObjectURL(item.url);
557
+ return prev.filter((_, i) => i !== index);
558
+ });
559
+ setCurrentIndex((idx) => {
560
+ if (idx === null) return idx;
561
+ if (index === idx) {
562
+ const newLen = playlistRef.current.length - 1;
563
+ if (newLen === 0) return null;
564
+ return Math.min(idx, newLen - 1);
565
+ }
566
+ if (index < idx) return idx - 1;
567
+ return idx;
568
+ });
569
+ },
570
+ []
571
+ );
572
+ const reorderItems = react.useCallback((newPlaylist) => {
573
+ setPlaylist(newPlaylist);
574
+ setCurrentIndex((idx) => {
575
+ if (idx === null) return idx;
576
+ const currentItem2 = playlistRef.current[idx];
577
+ if (!currentItem2) return idx;
578
+ const newIdx = newPlaylist.findIndex((i) => i.id === currentItem2.id);
579
+ return newIdx === -1 ? idx : newIdx;
580
+ });
581
+ }, []);
582
+ const replaceWithFile = react.useCallback((file) => {
583
+ setPlaylist((prev) => {
584
+ prev.forEach((item) => {
585
+ if (item.file) URL.revokeObjectURL(item.url);
586
+ });
587
+ const url = URL.createObjectURL(file);
588
+ return [
589
+ {
590
+ id: crypto.randomUUID(),
591
+ name: file.name,
592
+ url,
593
+ type: "video",
594
+ file
595
+ }
596
+ ];
597
+ });
598
+ setCurrentIndex(0);
599
+ }, []);
600
+ const addFiles = react.useCallback(async (files) => {
601
+ const items = await Promise.all(
602
+ files.map(async (file) => {
603
+ const url = URL.createObjectURL(file);
604
+ const duration = await getFileDuration(url);
605
+ return {
606
+ id: crypto.randomUUID(),
607
+ name: file.name,
608
+ url,
609
+ type: "video",
610
+ file,
611
+ duration
612
+ };
613
+ })
614
+ );
615
+ setPlaylist((prev) => [...prev, ...items]);
616
+ return items;
617
+ }, []);
618
+ const nextItem = react.useCallback(() => {
619
+ setCurrentIndex((idx) => {
620
+ if (idx === null || playlistRef.current.length <= 1) return idx;
621
+ return (idx + 1) % playlistRef.current.length;
622
+ });
623
+ }, []);
624
+ const prevItem = react.useCallback(() => {
625
+ setCurrentIndex((idx) => {
626
+ if (idx === null || playlistRef.current.length <= 1) return idx;
627
+ return (idx - 1 + playlistRef.current.length) % playlistRef.current.length;
628
+ });
629
+ }, []);
630
+ const parseFiles = react.useCallback(
631
+ (files) => {
632
+ const videoFiles = [];
633
+ const subtitleFiles = [];
634
+ Array.from(files).forEach((file) => {
635
+ const fileName = file.name.toLowerCase();
636
+ if (file.type.startsWith("video/") || VIDEO_EXTENSIONS.some((ext) => fileName.endsWith(ext))) {
637
+ videoFiles.push(file);
638
+ } else if (SUBTITLE_EXTENSIONS.some((ext) => fileName.endsWith(ext))) {
639
+ subtitleFiles.push(file);
640
+ }
641
+ });
642
+ return { videoFiles, subtitleFiles };
643
+ },
644
+ []
645
+ );
646
+ return {
647
+ playlist,
648
+ currentIndex,
649
+ currentItem,
650
+ selectItem,
651
+ appendItem,
652
+ removeItem,
653
+ reorderItems,
654
+ addFiles,
655
+ replaceWithFile,
656
+ nextItem,
657
+ prevItem,
658
+ parseFiles,
659
+ setPlaylist,
660
+ setCurrentIndex
661
+ };
662
+ }
663
+
664
+ // src/utils/keyboard-shortcuts.ts
665
+ function matchesShortcut(e, binding) {
666
+ const keyMatches = e.key === binding.key || e.key.toLowerCase() === binding.key.toLowerCase();
667
+ if (!keyMatches) return false;
668
+ if (!!binding.modifiers?.ctrl !== e.ctrlKey) return false;
669
+ if (!!binding.modifiers?.shift !== e.shiftKey) return false;
670
+ if (!!binding.modifiers?.alt !== e.altKey) return false;
671
+ return true;
672
+ }
673
+ function isInteractiveElement(el) {
674
+ if (!el || !(el instanceof HTMLElement)) return false;
675
+ const tag = el.tagName.toLowerCase();
676
+ return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
677
+ }
678
+
679
+ // src/react/use-keyboard-shortcuts.ts
680
+ function useKeyboardShortcuts(shortcuts, handlers) {
681
+ react.useEffect(() => {
682
+ const handler = (e) => {
683
+ if (isInteractiveElement(document.activeElement)) return;
684
+ for (const binding of shortcuts) {
685
+ if (matchesShortcut(e, binding)) {
686
+ e.preventDefault();
687
+ handlers[binding.action]?.();
688
+ break;
689
+ }
690
+ }
691
+ };
692
+ document.addEventListener("keydown", handler);
693
+ return () => document.removeEventListener("keydown", handler);
694
+ }, [shortcuts, handlers]);
695
+ }
696
+ function useFullscreen(containerRef) {
697
+ const [isFullscreen, setIsFullscreen] = react.useState(false);
698
+ react.useEffect(() => {
699
+ const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
700
+ document.addEventListener("fullscreenchange", onFullscreenChange);
701
+ return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
702
+ }, []);
703
+ const toggle = react.useCallback(() => {
704
+ const container = containerRef.current;
705
+ if (!container) return;
706
+ if (!document.fullscreenElement) {
707
+ container.requestFullscreen().catch((err) => {
708
+ console.error(`Fullscreen error: ${err.message}`);
709
+ });
710
+ } else {
711
+ document.exitFullscreen();
712
+ }
713
+ }, [containerRef]);
714
+ return { isFullscreen, toggle };
715
+ }
716
+ function usePictureInPicture(videoRef) {
717
+ const [isPiP, setIsPiP] = react.useState(false);
718
+ const isSupported = typeof document !== "undefined" && "pictureInPictureEnabled" in document && document.pictureInPictureEnabled;
719
+ react.useEffect(() => {
720
+ const video = videoRef.current;
721
+ if (!video) return;
722
+ const onEnter = () => setIsPiP(true);
723
+ const onLeave = () => setIsPiP(false);
724
+ video.addEventListener("enterpictureinpicture", onEnter);
725
+ video.addEventListener("leavepictureinpicture", onLeave);
726
+ return () => {
727
+ video.removeEventListener("enterpictureinpicture", onEnter);
728
+ video.removeEventListener("leavepictureinpicture", onLeave);
729
+ };
730
+ }, [videoRef]);
731
+ const enter = react.useCallback(async () => {
732
+ const video = videoRef.current;
733
+ if (!video) return;
734
+ try {
735
+ await video.requestPictureInPicture();
736
+ } catch (err) {
737
+ console.error("Failed to enter picture-in-picture:", err);
738
+ }
739
+ }, [videoRef]);
740
+ const exit = react.useCallback(async () => {
741
+ try {
742
+ await document.exitPictureInPicture();
743
+ } catch (err) {
744
+ console.error("Failed to exit picture-in-picture:", err);
745
+ }
746
+ }, []);
747
+ const toggle = react.useCallback(async () => {
748
+ if (isPiP) {
749
+ await exit();
750
+ } else {
751
+ await enter();
752
+ }
753
+ }, [isPiP, enter, exit]);
754
+ return { isPiP, isSupported, enter, exit, toggle };
755
+ }
756
+ var STORAGE_PREFIX = "lightbirdplayer-";
757
+ var SAVE_DEBOUNCE_MS = 5e3;
758
+ function useProgressPersistence(videoRef, currentVideoName) {
759
+ react.useEffect(() => {
760
+ if (!currentVideoName || !videoRef.current) return;
761
+ const saved = localStorage.getItem(`${STORAGE_PREFIX}${currentVideoName}`);
762
+ if (saved) {
763
+ const time = parseFloat(saved);
764
+ if (!isNaN(time)) videoRef.current.currentTime = time;
765
+ }
766
+ }, [currentVideoName, videoRef]);
767
+ react.useEffect(() => {
768
+ const el = videoRef.current;
769
+ if (!el || !currentVideoName) return;
770
+ let timer = null;
771
+ const onTimeUpdate = () => {
772
+ if (timer) clearTimeout(timer);
773
+ timer = setTimeout(() => {
774
+ localStorage.setItem(`${STORAGE_PREFIX}${currentVideoName}`, String(el.currentTime));
775
+ }, SAVE_DEBOUNCE_MS);
776
+ };
777
+ el.addEventListener("timeupdate", onTimeUpdate);
778
+ return () => {
779
+ el.removeEventListener("timeupdate", onTimeUpdate);
780
+ if (timer) {
781
+ clearTimeout(timer);
782
+ localStorage.setItem(`${STORAGE_PREFIX}${currentVideoName}`, String(el.currentTime));
783
+ }
784
+ };
785
+ }, [currentVideoName, videoRef]);
786
+ }
787
+
788
+ // src/utils/video-info.ts
789
+ function detectContainerFromUrl(url) {
790
+ const ext = url.split("?")[0].split(".").pop()?.toUpperCase();
791
+ return ext ?? "Unknown";
792
+ }
793
+ function extractNativeMetadata(videoEl, file) {
794
+ const container = file ? file.name.split(".").pop()?.toUpperCase() ?? "Unknown" : detectContainerFromUrl(videoEl.currentSrc);
795
+ return {
796
+ filename: file?.name ?? videoEl.currentSrc,
797
+ fileSize: file?.size ?? null,
798
+ duration: videoEl.duration || 0,
799
+ container,
800
+ width: videoEl.videoWidth,
801
+ height: videoEl.videoHeight,
802
+ frameRate: null,
803
+ videoBitrate: null,
804
+ videoCodec: null,
805
+ colorSpace: null,
806
+ audioTracks: [],
807
+ subtitleTracks: []
808
+ };
809
+ }
810
+
811
+ // src/react/use-video-info.ts
812
+ function useVideoInfo(videoRef, currentFile) {
813
+ const [metadata, setMetadata] = react.useState(null);
814
+ react.useEffect(() => {
815
+ const el = videoRef.current;
816
+ if (!el) return;
817
+ const onLoaded = () => {
818
+ const native = extractNativeMetadata(el, currentFile ?? void 0);
819
+ setMetadata((prev) => ({ ...prev, ...native }));
820
+ };
821
+ el.addEventListener("loadedmetadata", onLoaded);
822
+ return () => el.removeEventListener("loadedmetadata", onLoaded);
823
+ }, [videoRef, currentFile]);
824
+ react.useEffect(() => {
825
+ if (!currentFile) setMetadata(null);
826
+ }, [currentFile]);
827
+ const enrichMetadata = react.useCallback((extra) => {
828
+ setMetadata((prev) => prev ? { ...prev, ...extra } : null);
829
+ }, []);
830
+ return { metadata, enrichMetadata };
831
+ }
832
+ function useMediaSession(options) {
833
+ const { title, artwork, onPlay, onPause, onNext, onPrev, onSeekForward, onSeekBackward } = options;
834
+ react.useEffect(() => {
835
+ if (!("mediaSession" in navigator) || !navigator.mediaSession) return;
836
+ if (!title) {
837
+ navigator.mediaSession.metadata = null;
838
+ return;
839
+ }
840
+ const artworkList = artwork ? [{ src: artwork, sizes: "320x180", type: "image/jpeg" }] : [];
841
+ navigator.mediaSession.metadata = new MediaMetadata({
842
+ title,
843
+ artist: "LightBird",
844
+ artwork: artworkList
845
+ });
846
+ }, [title, artwork]);
847
+ react.useEffect(() => {
848
+ if (!("mediaSession" in navigator) || !navigator.mediaSession) return;
849
+ navigator.mediaSession.setActionHandler("play", onPlay);
850
+ navigator.mediaSession.setActionHandler("pause", onPause);
851
+ navigator.mediaSession.setActionHandler("nexttrack", onNext);
852
+ navigator.mediaSession.setActionHandler("previoustrack", onPrev);
853
+ navigator.mediaSession.setActionHandler("seekforward", ({ seekOffset }) => onSeekForward());
854
+ navigator.mediaSession.setActionHandler("seekbackward", ({ seekOffset }) => onSeekBackward());
855
+ return () => {
856
+ navigator.mediaSession.setActionHandler("play", null);
857
+ navigator.mediaSession.setActionHandler("pause", null);
858
+ navigator.mediaSession.setActionHandler("nexttrack", null);
859
+ navigator.mediaSession.setActionHandler("previoustrack", null);
860
+ navigator.mediaSession.setActionHandler("seekforward", null);
861
+ navigator.mediaSession.setActionHandler("seekbackward", null);
862
+ };
863
+ }, [onPlay, onPause, onNext, onPrev, onSeekForward, onSeekBackward]);
864
+ react.useEffect(() => {
865
+ return () => {
866
+ if (!("mediaSession" in navigator) || !navigator.mediaSession) return;
867
+ navigator.mediaSession.metadata = null;
868
+ };
869
+ }, []);
870
+ }
871
+ function useChapters(videoRef, playerRef) {
872
+ const [chapters, setChapters] = react.useState([]);
873
+ const [currentChapter, setCurrentChapter] = react.useState(null);
874
+ react.useEffect(() => {
875
+ const player = playerRef.current;
876
+ const loaded = player?.getChapters?.() ?? [];
877
+ setChapters(loaded);
878
+ setCurrentChapter(null);
879
+ }, [playerRef.current]);
880
+ react.useEffect(() => {
881
+ const el = videoRef.current;
882
+ if (!el || chapters.length === 0) {
883
+ setCurrentChapter(null);
884
+ return;
885
+ }
886
+ const onTimeUpdate = () => {
887
+ const t = el.currentTime;
888
+ const active = chapters.find((c) => t >= c.startTime && t < c.endTime) ?? null;
889
+ setCurrentChapter(active);
890
+ };
891
+ el.addEventListener("timeupdate", onTimeUpdate);
892
+ return () => el.removeEventListener("timeupdate", onTimeUpdate);
893
+ }, [videoRef, chapters]);
894
+ const goToChapter = react.useCallback(
895
+ (index) => {
896
+ const el = videoRef.current;
897
+ if (!el || !chapters[index]) return;
898
+ el.currentTime = chapters[index].startTime;
899
+ },
900
+ [videoRef, chapters]
901
+ );
902
+ return { chapters, currentChapter, goToChapter };
903
+ }
904
+
905
+ exports.useChapters = useChapters;
906
+ exports.useFullscreen = useFullscreen;
907
+ exports.useKeyboardShortcuts = useKeyboardShortcuts;
908
+ exports.useMediaSession = useMediaSession;
909
+ exports.usePictureInPicture = usePictureInPicture;
910
+ exports.usePlaylist = usePlaylist;
911
+ exports.useProgressPersistence = useProgressPersistence;
912
+ exports.useSubtitles = useSubtitles;
913
+ exports.useVideoFilters = useVideoFilters;
914
+ exports.useVideoInfo = useVideoInfo;
915
+ exports.useVideoPlayback = useVideoPlayback;