@lightbird/core 0.5.0 → 0.7.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 CHANGED
@@ -239,6 +239,121 @@ function parseVttTimestamp(ts) {
239
239
  return NaN;
240
240
  }
241
241
 
242
+ // src/utils/language-names.ts
243
+ var LANGUAGE_NAMES = {
244
+ // English
245
+ en: "English",
246
+ eng: "English",
247
+ // Japanese
248
+ ja: "Japanese",
249
+ jpn: "Japanese",
250
+ // Chinese
251
+ zh: "Chinese",
252
+ chi: "Chinese",
253
+ zho: "Chinese",
254
+ // Korean
255
+ ko: "Korean",
256
+ kor: "Korean",
257
+ // French
258
+ fr: "French",
259
+ fre: "French",
260
+ fra: "French",
261
+ // German
262
+ de: "German",
263
+ ger: "German",
264
+ deu: "German",
265
+ // Spanish
266
+ es: "Spanish",
267
+ spa: "Spanish",
268
+ // Italian
269
+ it: "Italian",
270
+ ita: "Italian",
271
+ // Portuguese
272
+ pt: "Portuguese",
273
+ por: "Portuguese",
274
+ // Russian
275
+ ru: "Russian",
276
+ rus: "Russian",
277
+ // Dutch
278
+ nl: "Dutch",
279
+ dut: "Dutch",
280
+ nld: "Dutch",
281
+ // Polish
282
+ pl: "Polish",
283
+ pol: "Polish",
284
+ // Arabic
285
+ ar: "Arabic",
286
+ ara: "Arabic",
287
+ // Hindi
288
+ hi: "Hindi",
289
+ hin: "Hindi",
290
+ // Bengali
291
+ bn: "Bengali",
292
+ ben: "Bengali",
293
+ // Turkish
294
+ tr: "Turkish",
295
+ tur: "Turkish",
296
+ // Swedish
297
+ sv: "Swedish",
298
+ swe: "Swedish",
299
+ // Norwegian
300
+ no: "Norwegian",
301
+ nor: "Norwegian",
302
+ // Danish
303
+ da: "Danish",
304
+ dan: "Danish",
305
+ // Finnish
306
+ fi: "Finnish",
307
+ fin: "Finnish",
308
+ // Greek
309
+ el: "Greek",
310
+ gre: "Greek",
311
+ ell: "Greek",
312
+ // Hebrew
313
+ he: "Hebrew",
314
+ heb: "Hebrew",
315
+ // Thai
316
+ th: "Thai",
317
+ tha: "Thai",
318
+ // Vietnamese
319
+ vi: "Vietnamese",
320
+ vie: "Vietnamese",
321
+ // Indonesian
322
+ id: "Indonesian",
323
+ ind: "Indonesian",
324
+ // Malay
325
+ ms: "Malay",
326
+ may: "Malay",
327
+ msa: "Malay",
328
+ // Czech
329
+ cs: "Czech",
330
+ cze: "Czech",
331
+ ces: "Czech",
332
+ // Hungarian
333
+ hu: "Hungarian",
334
+ hun: "Hungarian",
335
+ // Romanian
336
+ ro: "Romanian",
337
+ rum: "Romanian",
338
+ ron: "Romanian",
339
+ // Ukrainian
340
+ uk: "Ukrainian",
341
+ ukr: "Ukrainian",
342
+ // Tamil
343
+ ta: "Tamil",
344
+ tam: "Tamil",
345
+ // Telugu
346
+ te: "Telugu",
347
+ tel: "Telugu"
348
+ };
349
+ var UNDETERMINED = /* @__PURE__ */ new Set(["", "und", "unknown", "mis", "zxx", "mul"]);
350
+ function getLanguageName(code) {
351
+ if (!code) return void 0;
352
+ const normalized = code.trim().toLowerCase();
353
+ if (UNDETERMINED.has(normalized)) return void 0;
354
+ return LANGUAGE_NAMES[normalized] ?? code.trim();
355
+ }
356
+
242
357
  // src/players/mkv-player.ts
243
358
  async function canPlayNatively(objectUrl, timeoutMs = 3e3) {
244
359
  return new Promise((resolve) => {
@@ -267,23 +382,76 @@ function parseStreamInfo(logs) {
267
382
  const videoTracks = [];
268
383
  const audioTracks = [];
269
384
  const subtitleTracks = [];
385
+ let current = null;
270
386
  const lines = logs.split("\n");
271
387
  for (const line of lines) {
272
- const streamMatch = line.match(/Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (\S+)/i);
273
- if (!streamMatch) continue;
274
- const [, lang, type, codec] = streamMatch;
275
- const titleMatch = line.match(/\btitle\s*:\s*([^,\n]+)/i);
276
- const title = titleMatch ? titleMatch[1].trim() : void 0;
277
- if (type.toLowerCase() === "video") {
278
- videoTracks.push({ index: videoTracks.length, type: "video", codec, lang, title });
279
- } else if (type.toLowerCase() === "audio") {
280
- audioTracks.push({ index: audioTracks.length, type: "audio", codec, lang, title });
281
- } else if (type.toLowerCase() === "subtitle") {
282
- subtitleTracks.push({ index: subtitleTracks.length, type: "subtitle", codec, lang, title });
388
+ if (/^\s*Stream #\d+:\d+/.test(line)) {
389
+ current = null;
390
+ }
391
+ const streamMatch = line.match(
392
+ /Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (.+)$/i
393
+ );
394
+ if (streamMatch) {
395
+ const [, lang, type, rest] = streamMatch;
396
+ const codec = rest.trim().split(/[\s,]/)[0];
397
+ const forced = /\(forced\)/i.test(rest);
398
+ const isDefault = /\(default\)/i.test(rest);
399
+ const t = type.toLowerCase();
400
+ const bucket = t === "video" ? videoTracks : t === "audio" ? audioTracks : subtitleTracks;
401
+ current = {
402
+ index: bucket.length,
403
+ type: t,
404
+ codec,
405
+ lang,
406
+ forced,
407
+ default: isDefault
408
+ };
409
+ bucket.push(current);
410
+ continue;
411
+ }
412
+ if (/^\s*(Chapter #|Program )/i.test(line)) {
413
+ current = null;
414
+ continue;
415
+ }
416
+ if (current && current.title === void 0) {
417
+ const titleMatch = line.match(/^\s*title\s*:\s*(.+)$/i);
418
+ if (titleMatch) {
419
+ current.title = titleMatch[1].trim();
420
+ }
283
421
  }
284
422
  }
285
423
  return { videoTracks, audioTracks, subtitleTracks };
286
424
  }
425
+ function formatTrackName(index, t) {
426
+ let label = t.title?.trim() || `Track ${index + 1}`;
427
+ const langName = getLanguageName(t.lang);
428
+ if (langName) label += ` - [${langName}]`;
429
+ if (t.forced) label += " [Forced]";
430
+ return label;
431
+ }
432
+ function buildAudioTracks(tracks) {
433
+ if (tracks.length === 0) {
434
+ return [{ id: "0", name: "Default Audio", lang: "unknown" }];
435
+ }
436
+ return tracks.map((t, i) => ({
437
+ id: String(i),
438
+ name: formatTrackName(i, t),
439
+ lang: t.lang ?? "unknown"
440
+ }));
441
+ }
442
+ function buildSubtitleTracks(tracks, trackMap) {
443
+ trackMap.clear();
444
+ return tracks.map((t, i) => {
445
+ const id = String(i);
446
+ trackMap.set(id, i);
447
+ return {
448
+ id,
449
+ name: formatTrackName(i, t),
450
+ lang: t.lang ?? "unknown",
451
+ type: "embedded"
452
+ };
453
+ });
454
+ }
287
455
  var _MKVPlayer = class _MKVPlayer {
288
456
  constructor(file, onProgress) {
289
457
  this.videoElement = null;
@@ -402,22 +570,11 @@ var _MKVPlayer = class _MKVPlayer {
402
570
  });
403
571
  const { audioTracks, subtitleTracks } = parseStreamInfo(result.logs);
404
572
  this.chapters = parseChaptersFromFFmpegLog(result.logs, videoElement.duration || 0);
405
- this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
406
- id: String(i),
407
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
408
- lang: t.lang ?? "unknown"
409
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
410
- this.subtitleTrackMap.clear();
411
- this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
412
- const id = String(i);
413
- this.subtitleTrackMap.set(id, i);
414
- return {
415
- id,
416
- name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
417
- lang: t.lang ?? "unknown",
418
- type: "embedded"
419
- };
420
- });
573
+ this.playerFile.audioTracks = buildAudioTracks(audioTracks);
574
+ this.playerFile.subtitleTracks = buildSubtitleTracks(
575
+ subtitleTracks,
576
+ this.subtitleTrackMap
577
+ );
421
578
  const blob = new Blob([result.data], { type: "video/mp4" });
422
579
  const url = URL.createObjectURL(blob);
423
580
  this.remuxCache.set(0, url);
@@ -448,22 +605,11 @@ var _MKVPlayer = class _MKVPlayer {
448
605
  });
449
606
  if (this._cancelled) return;
450
607
  const { audioTracks, subtitleTracks } = parseStreamInfo(probeResult.logs);
451
- this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
452
- id: String(i),
453
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
454
- lang: t.lang ?? "unknown"
455
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
456
- this.subtitleTrackMap.clear();
457
- this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
458
- const id = String(i);
459
- this.subtitleTrackMap.set(id, i);
460
- return {
461
- id,
462
- name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
463
- lang: t.lang ?? "unknown",
464
- type: "embedded"
465
- };
466
- });
608
+ this.playerFile.audioTracks = buildAudioTracks(audioTracks);
609
+ this.playerFile.subtitleTracks = buildSubtitleTracks(
610
+ subtitleTracks,
611
+ this.subtitleTrackMap
612
+ );
467
613
  }
468
614
  async _remux(audioTrackIndex) {
469
615
  const cached = this.remuxCache.get(audioTrackIndex);
@@ -1484,7 +1630,11 @@ var DEFAULT_SHORTCUTS = [
1484
1630
  { action: "screenshot", label: "Screenshot", defaultKey: "s", key: "s", modifiers: { ctrl: true } },
1485
1631
  { action: "show-shortcuts", label: "Show Shortcuts Help", defaultKey: "?", key: "?" },
1486
1632
  { action: "next-chapter", label: "Next Chapter", defaultKey: "]", key: "]" },
1487
- { action: "prev-chapter", label: "Previous Chapter", defaultKey: "[", key: "[" }
1633
+ { action: "prev-chapter", label: "Previous Chapter", defaultKey: "[", key: "[" },
1634
+ { action: "frame-step-forward", label: "Step Forward One Frame", defaultKey: ".", key: "." },
1635
+ { action: "frame-step-backward", label: "Step Backward One Frame", defaultKey: ",", key: "," },
1636
+ { action: "loop-toggle", label: "Toggle Loop", defaultKey: "l", key: "l" },
1637
+ { action: "ab-loop-cycle", label: "A-B Loop (Set/Cycle)", defaultKey: "r", key: "r" }
1488
1638
  ];
1489
1639
  var STORAGE_KEY = "lightbird-shortcuts";
1490
1640
  function loadShortcuts() {
@@ -1518,7 +1668,14 @@ function matchesShortcut(e, binding) {
1518
1668
  function isInteractiveElement(el) {
1519
1669
  if (!el || !(el instanceof HTMLElement)) return false;
1520
1670
  const tag = el.tagName.toLowerCase();
1521
- return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
1671
+ if (["input", "textarea", "select"].includes(tag)) return true;
1672
+ if (el.contentEditable === "true" || el.getAttribute("contenteditable") !== null) return true;
1673
+ if (typeof el.closest === "function" && el.closest(
1674
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [data-radix-popper-content-wrapper]'
1675
+ )) {
1676
+ return true;
1677
+ }
1678
+ return false;
1522
1679
  }
1523
1680
  function formatShortcutKey(binding) {
1524
1681
  const mods = [];
@@ -1620,6 +1777,7 @@ exports.exportPlaylist = exportPlaylist;
1620
1777
  exports.extractNativeMetadata = extractNativeMetadata;
1621
1778
  exports.formatShortcutKey = formatShortcutKey;
1622
1779
  exports.getFFmpeg = getFFmpeg;
1780
+ exports.getLanguageName = getLanguageName;
1623
1781
  exports.getVideoFiles = getVideoFiles;
1624
1782
  exports.getWebTorrentClient = getWebTorrentClient;
1625
1783
  exports.hasAcceptedDisclaimer = hasAcceptedDisclaimer;
package/dist/index.d.cts CHANGED
@@ -388,7 +388,7 @@ declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: nu
388
388
  */
389
389
  declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
390
390
 
391
- type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
391
+ type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter' | 'frame-step-forward' | 'frame-step-backward' | 'loop-toggle' | 'ab-loop-cycle';
392
392
  interface ShortcutBinding {
393
393
  action: ShortcutAction;
394
394
  label: string;
@@ -441,4 +441,13 @@ declare class ProgressEstimator {
441
441
  declare function getFFmpeg(): Promise<FFmpeg>;
442
442
  declare function resetFFmpeg(): void;
443
443
 
444
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
444
+ /**
445
+ * Resolve an ISO 639 language code to its English language name, mirroring how
446
+ * VLC labels tracks.
447
+ *
448
+ * @returns The full language name, or the original code if it's unrecognised
449
+ * (VLC behaviour), or `undefined` if the code is absent/undetermined.
450
+ */
451
+ declare function getLanguageName(code?: string | null): string | undefined;
452
+
453
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.d.ts CHANGED
@@ -388,7 +388,7 @@ declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: nu
388
388
  */
389
389
  declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
390
390
 
391
- type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
391
+ type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter' | 'frame-step-forward' | 'frame-step-backward' | 'loop-toggle' | 'ab-loop-cycle';
392
392
  interface ShortcutBinding {
393
393
  action: ShortcutAction;
394
394
  label: string;
@@ -441,4 +441,13 @@ declare class ProgressEstimator {
441
441
  declare function getFFmpeg(): Promise<FFmpeg>;
442
442
  declare function resetFFmpeg(): void;
443
443
 
444
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
444
+ /**
445
+ * Resolve an ISO 639 language code to its English language name, mirroring how
446
+ * VLC labels tracks.
447
+ *
448
+ * @returns The full language name, or the original code if it's unrecognised
449
+ * (VLC behaviour), or `undefined` if the code is absent/undetermined.
450
+ */
451
+ declare function getLanguageName(code?: string | null): string | undefined;
452
+
453
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.js CHANGED
@@ -236,6 +236,121 @@ function parseVttTimestamp(ts) {
236
236
  return NaN;
237
237
  }
238
238
 
239
+ // src/utils/language-names.ts
240
+ var LANGUAGE_NAMES = {
241
+ // English
242
+ en: "English",
243
+ eng: "English",
244
+ // Japanese
245
+ ja: "Japanese",
246
+ jpn: "Japanese",
247
+ // Chinese
248
+ zh: "Chinese",
249
+ chi: "Chinese",
250
+ zho: "Chinese",
251
+ // Korean
252
+ ko: "Korean",
253
+ kor: "Korean",
254
+ // French
255
+ fr: "French",
256
+ fre: "French",
257
+ fra: "French",
258
+ // German
259
+ de: "German",
260
+ ger: "German",
261
+ deu: "German",
262
+ // Spanish
263
+ es: "Spanish",
264
+ spa: "Spanish",
265
+ // Italian
266
+ it: "Italian",
267
+ ita: "Italian",
268
+ // Portuguese
269
+ pt: "Portuguese",
270
+ por: "Portuguese",
271
+ // Russian
272
+ ru: "Russian",
273
+ rus: "Russian",
274
+ // Dutch
275
+ nl: "Dutch",
276
+ dut: "Dutch",
277
+ nld: "Dutch",
278
+ // Polish
279
+ pl: "Polish",
280
+ pol: "Polish",
281
+ // Arabic
282
+ ar: "Arabic",
283
+ ara: "Arabic",
284
+ // Hindi
285
+ hi: "Hindi",
286
+ hin: "Hindi",
287
+ // Bengali
288
+ bn: "Bengali",
289
+ ben: "Bengali",
290
+ // Turkish
291
+ tr: "Turkish",
292
+ tur: "Turkish",
293
+ // Swedish
294
+ sv: "Swedish",
295
+ swe: "Swedish",
296
+ // Norwegian
297
+ no: "Norwegian",
298
+ nor: "Norwegian",
299
+ // Danish
300
+ da: "Danish",
301
+ dan: "Danish",
302
+ // Finnish
303
+ fi: "Finnish",
304
+ fin: "Finnish",
305
+ // Greek
306
+ el: "Greek",
307
+ gre: "Greek",
308
+ ell: "Greek",
309
+ // Hebrew
310
+ he: "Hebrew",
311
+ heb: "Hebrew",
312
+ // Thai
313
+ th: "Thai",
314
+ tha: "Thai",
315
+ // Vietnamese
316
+ vi: "Vietnamese",
317
+ vie: "Vietnamese",
318
+ // Indonesian
319
+ id: "Indonesian",
320
+ ind: "Indonesian",
321
+ // Malay
322
+ ms: "Malay",
323
+ may: "Malay",
324
+ msa: "Malay",
325
+ // Czech
326
+ cs: "Czech",
327
+ cze: "Czech",
328
+ ces: "Czech",
329
+ // Hungarian
330
+ hu: "Hungarian",
331
+ hun: "Hungarian",
332
+ // Romanian
333
+ ro: "Romanian",
334
+ rum: "Romanian",
335
+ ron: "Romanian",
336
+ // Ukrainian
337
+ uk: "Ukrainian",
338
+ ukr: "Ukrainian",
339
+ // Tamil
340
+ ta: "Tamil",
341
+ tam: "Tamil",
342
+ // Telugu
343
+ te: "Telugu",
344
+ tel: "Telugu"
345
+ };
346
+ var UNDETERMINED = /* @__PURE__ */ new Set(["", "und", "unknown", "mis", "zxx", "mul"]);
347
+ function getLanguageName(code) {
348
+ if (!code) return void 0;
349
+ const normalized = code.trim().toLowerCase();
350
+ if (UNDETERMINED.has(normalized)) return void 0;
351
+ return LANGUAGE_NAMES[normalized] ?? code.trim();
352
+ }
353
+
239
354
  // src/players/mkv-player.ts
240
355
  async function canPlayNatively(objectUrl, timeoutMs = 3e3) {
241
356
  return new Promise((resolve) => {
@@ -264,23 +379,76 @@ function parseStreamInfo(logs) {
264
379
  const videoTracks = [];
265
380
  const audioTracks = [];
266
381
  const subtitleTracks = [];
382
+ let current = null;
267
383
  const lines = logs.split("\n");
268
384
  for (const line of lines) {
269
- const streamMatch = line.match(/Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (\S+)/i);
270
- if (!streamMatch) continue;
271
- const [, lang, type, codec] = streamMatch;
272
- const titleMatch = line.match(/\btitle\s*:\s*([^,\n]+)/i);
273
- const title = titleMatch ? titleMatch[1].trim() : void 0;
274
- if (type.toLowerCase() === "video") {
275
- videoTracks.push({ index: videoTracks.length, type: "video", codec, lang, title });
276
- } else if (type.toLowerCase() === "audio") {
277
- audioTracks.push({ index: audioTracks.length, type: "audio", codec, lang, title });
278
- } else if (type.toLowerCase() === "subtitle") {
279
- subtitleTracks.push({ index: subtitleTracks.length, type: "subtitle", codec, lang, title });
385
+ if (/^\s*Stream #\d+:\d+/.test(line)) {
386
+ current = null;
387
+ }
388
+ const streamMatch = line.match(
389
+ /Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (.+)$/i
390
+ );
391
+ if (streamMatch) {
392
+ const [, lang, type, rest] = streamMatch;
393
+ const codec = rest.trim().split(/[\s,]/)[0];
394
+ const forced = /\(forced\)/i.test(rest);
395
+ const isDefault = /\(default\)/i.test(rest);
396
+ const t = type.toLowerCase();
397
+ const bucket = t === "video" ? videoTracks : t === "audio" ? audioTracks : subtitleTracks;
398
+ current = {
399
+ index: bucket.length,
400
+ type: t,
401
+ codec,
402
+ lang,
403
+ forced,
404
+ default: isDefault
405
+ };
406
+ bucket.push(current);
407
+ continue;
408
+ }
409
+ if (/^\s*(Chapter #|Program )/i.test(line)) {
410
+ current = null;
411
+ continue;
412
+ }
413
+ if (current && current.title === void 0) {
414
+ const titleMatch = line.match(/^\s*title\s*:\s*(.+)$/i);
415
+ if (titleMatch) {
416
+ current.title = titleMatch[1].trim();
417
+ }
280
418
  }
281
419
  }
282
420
  return { videoTracks, audioTracks, subtitleTracks };
283
421
  }
422
+ function formatTrackName(index, t) {
423
+ let label = t.title?.trim() || `Track ${index + 1}`;
424
+ const langName = getLanguageName(t.lang);
425
+ if (langName) label += ` - [${langName}]`;
426
+ if (t.forced) label += " [Forced]";
427
+ return label;
428
+ }
429
+ function buildAudioTracks(tracks) {
430
+ if (tracks.length === 0) {
431
+ return [{ id: "0", name: "Default Audio", lang: "unknown" }];
432
+ }
433
+ return tracks.map((t, i) => ({
434
+ id: String(i),
435
+ name: formatTrackName(i, t),
436
+ lang: t.lang ?? "unknown"
437
+ }));
438
+ }
439
+ function buildSubtitleTracks(tracks, trackMap) {
440
+ trackMap.clear();
441
+ return tracks.map((t, i) => {
442
+ const id = String(i);
443
+ trackMap.set(id, i);
444
+ return {
445
+ id,
446
+ name: formatTrackName(i, t),
447
+ lang: t.lang ?? "unknown",
448
+ type: "embedded"
449
+ };
450
+ });
451
+ }
284
452
  var _MKVPlayer = class _MKVPlayer {
285
453
  constructor(file, onProgress) {
286
454
  this.videoElement = null;
@@ -399,22 +567,11 @@ var _MKVPlayer = class _MKVPlayer {
399
567
  });
400
568
  const { audioTracks, subtitleTracks } = parseStreamInfo(result.logs);
401
569
  this.chapters = parseChaptersFromFFmpegLog(result.logs, videoElement.duration || 0);
402
- this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
403
- id: String(i),
404
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
405
- lang: t.lang ?? "unknown"
406
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
407
- this.subtitleTrackMap.clear();
408
- this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
409
- const id = String(i);
410
- this.subtitleTrackMap.set(id, i);
411
- return {
412
- id,
413
- name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
414
- lang: t.lang ?? "unknown",
415
- type: "embedded"
416
- };
417
- });
570
+ this.playerFile.audioTracks = buildAudioTracks(audioTracks);
571
+ this.playerFile.subtitleTracks = buildSubtitleTracks(
572
+ subtitleTracks,
573
+ this.subtitleTrackMap
574
+ );
418
575
  const blob = new Blob([result.data], { type: "video/mp4" });
419
576
  const url = URL.createObjectURL(blob);
420
577
  this.remuxCache.set(0, url);
@@ -445,22 +602,11 @@ var _MKVPlayer = class _MKVPlayer {
445
602
  });
446
603
  if (this._cancelled) return;
447
604
  const { audioTracks, subtitleTracks } = parseStreamInfo(probeResult.logs);
448
- this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
449
- id: String(i),
450
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
451
- lang: t.lang ?? "unknown"
452
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
453
- this.subtitleTrackMap.clear();
454
- this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
455
- const id = String(i);
456
- this.subtitleTrackMap.set(id, i);
457
- return {
458
- id,
459
- name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
460
- lang: t.lang ?? "unknown",
461
- type: "embedded"
462
- };
463
- });
605
+ this.playerFile.audioTracks = buildAudioTracks(audioTracks);
606
+ this.playerFile.subtitleTracks = buildSubtitleTracks(
607
+ subtitleTracks,
608
+ this.subtitleTrackMap
609
+ );
464
610
  }
465
611
  async _remux(audioTrackIndex) {
466
612
  const cached = this.remuxCache.get(audioTrackIndex);
@@ -1481,7 +1627,11 @@ var DEFAULT_SHORTCUTS = [
1481
1627
  { action: "screenshot", label: "Screenshot", defaultKey: "s", key: "s", modifiers: { ctrl: true } },
1482
1628
  { action: "show-shortcuts", label: "Show Shortcuts Help", defaultKey: "?", key: "?" },
1483
1629
  { action: "next-chapter", label: "Next Chapter", defaultKey: "]", key: "]" },
1484
- { action: "prev-chapter", label: "Previous Chapter", defaultKey: "[", key: "[" }
1630
+ { action: "prev-chapter", label: "Previous Chapter", defaultKey: "[", key: "[" },
1631
+ { action: "frame-step-forward", label: "Step Forward One Frame", defaultKey: ".", key: "." },
1632
+ { action: "frame-step-backward", label: "Step Backward One Frame", defaultKey: ",", key: "," },
1633
+ { action: "loop-toggle", label: "Toggle Loop", defaultKey: "l", key: "l" },
1634
+ { action: "ab-loop-cycle", label: "A-B Loop (Set/Cycle)", defaultKey: "r", key: "r" }
1485
1635
  ];
1486
1636
  var STORAGE_KEY = "lightbird-shortcuts";
1487
1637
  function loadShortcuts() {
@@ -1515,7 +1665,14 @@ function matchesShortcut(e, binding) {
1515
1665
  function isInteractiveElement(el) {
1516
1666
  if (!el || !(el instanceof HTMLElement)) return false;
1517
1667
  const tag = el.tagName.toLowerCase();
1518
- return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
1668
+ if (["input", "textarea", "select"].includes(tag)) return true;
1669
+ if (el.contentEditable === "true" || el.getAttribute("contenteditable") !== null) return true;
1670
+ if (typeof el.closest === "function" && el.closest(
1671
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [data-radix-popper-content-wrapper]'
1672
+ )) {
1673
+ return true;
1674
+ }
1675
+ return false;
1519
1676
  }
1520
1677
  function formatShortcutKey(binding) {
1521
1678
  const mods = [];
@@ -1592,4 +1749,4 @@ function resetFFmpeg() {
1592
1749
  loading = null;
1593
1750
  }
1594
1751
 
1595
- export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, VIDEO_EXTENSIONS, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
1752
+ export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, VIDEO_EXTENSIONS, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
@@ -673,7 +673,14 @@ function matchesShortcut(e, binding) {
673
673
  function isInteractiveElement(el) {
674
674
  if (!el || !(el instanceof HTMLElement)) return false;
675
675
  const tag = el.tagName.toLowerCase();
676
- return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
676
+ if (["input", "textarea", "select"].includes(tag)) return true;
677
+ if (el.contentEditable === "true" || el.getAttribute("contenteditable") !== null) return true;
678
+ if (typeof el.closest === "function" && el.closest(
679
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [data-radix-popper-content-wrapper]'
680
+ )) {
681
+ return true;
682
+ }
683
+ return false;
677
684
  }
678
685
 
679
686
  // src/react/use-keyboard-shortcuts.ts
@@ -1332,6 +1339,66 @@ function useABLoop(videoRef) {
1332
1339
  clear
1333
1340
  };
1334
1341
  }
1342
+ function useSmoothProgress(videoRef, { isPlaying, fallback = 0 }) {
1343
+ const [progress, setProgress] = react.useState(() => {
1344
+ const el = videoRef.current;
1345
+ return el ? el.currentTime : fallback;
1346
+ });
1347
+ const rafRef = react.useRef(null);
1348
+ react.useEffect(() => {
1349
+ const el = videoRef.current;
1350
+ if (!el) {
1351
+ setProgress(fallback);
1352
+ return;
1353
+ }
1354
+ if (!isPlaying) {
1355
+ setProgress(el.currentTime);
1356
+ return;
1357
+ }
1358
+ const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
1359
+ let cancelled = false;
1360
+ const tick = () => {
1361
+ if (cancelled) return;
1362
+ const current = videoRef.current;
1363
+ if (!current) {
1364
+ setProgress(fallback);
1365
+ stop();
1366
+ return;
1367
+ }
1368
+ setProgress(current.currentTime);
1369
+ rafRef.current = requestAnimationFrame(tick);
1370
+ };
1371
+ const start = () => {
1372
+ if (rafRef.current != null || isHidden()) return;
1373
+ rafRef.current = requestAnimationFrame(tick);
1374
+ };
1375
+ const stop = () => {
1376
+ if (rafRef.current != null) {
1377
+ cancelAnimationFrame(rafRef.current);
1378
+ rafRef.current = null;
1379
+ }
1380
+ };
1381
+ const onVisibility = () => {
1382
+ if (isHidden()) {
1383
+ stop();
1384
+ } else {
1385
+ start();
1386
+ }
1387
+ };
1388
+ start();
1389
+ if (typeof document !== "undefined") {
1390
+ document.addEventListener("visibilitychange", onVisibility);
1391
+ }
1392
+ return () => {
1393
+ cancelled = true;
1394
+ stop();
1395
+ if (typeof document !== "undefined") {
1396
+ document.removeEventListener("visibilitychange", onVisibility);
1397
+ }
1398
+ };
1399
+ }, [isPlaying, videoRef, fallback]);
1400
+ return progress;
1401
+ }
1335
1402
  var SWIPE_THRESHOLD = 10;
1336
1403
  var DOUBLE_TAP_DISTANCE = 40;
1337
1404
  function useTouchGestures(targetRef, handlers, options = {}) {
@@ -1463,6 +1530,7 @@ exports.usePictureInPicture = usePictureInPicture;
1463
1530
  exports.usePlaylist = usePlaylist;
1464
1531
  exports.useProgressPersistence = useProgressPersistence;
1465
1532
  exports.useSeekPreview = useSeekPreview;
1533
+ exports.useSmoothProgress = useSmoothProgress;
1466
1534
  exports.useSubtitles = useSubtitles;
1467
1535
  exports.useTouchGestures = useTouchGestures;
1468
1536
  exports.useVideoFilters = useVideoFilters;
@@ -183,7 +183,7 @@ declare function usePlaylist(): {
183
183
  setCurrentIndex: react.Dispatch<react.SetStateAction<number | null>>;
184
184
  };
185
185
 
186
- type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
186
+ type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter' | 'frame-step-forward' | 'frame-step-backward' | 'loop-toggle' | 'ab-loop-cycle';
187
187
  interface ShortcutBinding {
188
188
  action: ShortcutAction;
189
189
  label: string;
@@ -335,6 +335,20 @@ interface ABLoopState {
335
335
  */
336
336
  declare function useABLoop(videoRef: RefObject<HTMLVideoElement | null>): ABLoopState;
337
337
 
338
+ interface UseSmoothProgressOptions {
339
+ isPlaying: boolean;
340
+ fallback?: number;
341
+ }
342
+ /**
343
+ * Drives a `progress` value at requestAnimationFrame rate by reading
344
+ * `videoRef.current.currentTime` each frame while playing. Decouples the
345
+ * visual seek-bar position from the video element's coarse `timeupdate`
346
+ * events (~4Hz) so the thumb glides instead of stepping.
347
+ *
348
+ * Pauses the rAF loop when not playing or when the tab is hidden.
349
+ */
350
+ declare function useSmoothProgress(videoRef: RefObject<HTMLVideoElement | null>, { isPlaying, fallback }: UseSmoothProgressOptions): number;
351
+
338
352
  interface TouchGestureHandlers {
339
353
  /** Seek relative to the current time by a signed number of seconds. */
340
354
  seekBy?: (seconds: number) => void;
@@ -384,4 +398,4 @@ interface TouchGesturesState {
384
398
  */
385
399
  declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
386
400
 
387
- export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
401
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -183,7 +183,7 @@ declare function usePlaylist(): {
183
183
  setCurrentIndex: react.Dispatch<react.SetStateAction<number | null>>;
184
184
  };
185
185
 
186
- type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
186
+ type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter' | 'frame-step-forward' | 'frame-step-backward' | 'loop-toggle' | 'ab-loop-cycle';
187
187
  interface ShortcutBinding {
188
188
  action: ShortcutAction;
189
189
  label: string;
@@ -335,6 +335,20 @@ interface ABLoopState {
335
335
  */
336
336
  declare function useABLoop(videoRef: RefObject<HTMLVideoElement | null>): ABLoopState;
337
337
 
338
+ interface UseSmoothProgressOptions {
339
+ isPlaying: boolean;
340
+ fallback?: number;
341
+ }
342
+ /**
343
+ * Drives a `progress` value at requestAnimationFrame rate by reading
344
+ * `videoRef.current.currentTime` each frame while playing. Decouples the
345
+ * visual seek-bar position from the video element's coarse `timeupdate`
346
+ * events (~4Hz) so the thumb glides instead of stepping.
347
+ *
348
+ * Pauses the rAF loop when not playing or when the tab is hidden.
349
+ */
350
+ declare function useSmoothProgress(videoRef: RefObject<HTMLVideoElement | null>, { isPlaying, fallback }: UseSmoothProgressOptions): number;
351
+
338
352
  interface TouchGestureHandlers {
339
353
  /** Seek relative to the current time by a signed number of seconds. */
340
354
  seekBy?: (seconds: number) => void;
@@ -384,4 +398,4 @@ interface TouchGesturesState {
384
398
  */
385
399
  declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
386
400
 
387
- export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
401
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -671,7 +671,14 @@ function matchesShortcut(e, binding) {
671
671
  function isInteractiveElement(el) {
672
672
  if (!el || !(el instanceof HTMLElement)) return false;
673
673
  const tag = el.tagName.toLowerCase();
674
- return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
674
+ if (["input", "textarea", "select"].includes(tag)) return true;
675
+ if (el.contentEditable === "true" || el.getAttribute("contenteditable") !== null) return true;
676
+ if (typeof el.closest === "function" && el.closest(
677
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [data-radix-popper-content-wrapper]'
678
+ )) {
679
+ return true;
680
+ }
681
+ return false;
675
682
  }
676
683
 
677
684
  // src/react/use-keyboard-shortcuts.ts
@@ -1330,6 +1337,66 @@ function useABLoop(videoRef) {
1330
1337
  clear
1331
1338
  };
1332
1339
  }
1340
+ function useSmoothProgress(videoRef, { isPlaying, fallback = 0 }) {
1341
+ const [progress, setProgress] = useState(() => {
1342
+ const el = videoRef.current;
1343
+ return el ? el.currentTime : fallback;
1344
+ });
1345
+ const rafRef = useRef(null);
1346
+ useEffect(() => {
1347
+ const el = videoRef.current;
1348
+ if (!el) {
1349
+ setProgress(fallback);
1350
+ return;
1351
+ }
1352
+ if (!isPlaying) {
1353
+ setProgress(el.currentTime);
1354
+ return;
1355
+ }
1356
+ const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
1357
+ let cancelled = false;
1358
+ const tick = () => {
1359
+ if (cancelled) return;
1360
+ const current = videoRef.current;
1361
+ if (!current) {
1362
+ setProgress(fallback);
1363
+ stop();
1364
+ return;
1365
+ }
1366
+ setProgress(current.currentTime);
1367
+ rafRef.current = requestAnimationFrame(tick);
1368
+ };
1369
+ const start = () => {
1370
+ if (rafRef.current != null || isHidden()) return;
1371
+ rafRef.current = requestAnimationFrame(tick);
1372
+ };
1373
+ const stop = () => {
1374
+ if (rafRef.current != null) {
1375
+ cancelAnimationFrame(rafRef.current);
1376
+ rafRef.current = null;
1377
+ }
1378
+ };
1379
+ const onVisibility = () => {
1380
+ if (isHidden()) {
1381
+ stop();
1382
+ } else {
1383
+ start();
1384
+ }
1385
+ };
1386
+ start();
1387
+ if (typeof document !== "undefined") {
1388
+ document.addEventListener("visibilitychange", onVisibility);
1389
+ }
1390
+ return () => {
1391
+ cancelled = true;
1392
+ stop();
1393
+ if (typeof document !== "undefined") {
1394
+ document.removeEventListener("visibilitychange", onVisibility);
1395
+ }
1396
+ };
1397
+ }, [isPlaying, videoRef, fallback]);
1398
+ return progress;
1399
+ }
1333
1400
  var SWIPE_THRESHOLD = 10;
1334
1401
  var DOUBLE_TAP_DISTANCE = 40;
1335
1402
  function useTouchGestures(targetRef, handlers, options = {}) {
@@ -1451,4 +1518,4 @@ function useTouchGestures(targetRef, handlers, options = {}) {
1451
1518
  return { feedback };
1452
1519
  }
1453
1520
 
1454
- export { useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
1521
+ export { useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightbird/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Client-side video player engine. Plays MKV, MP4, WebM with full subtitle, audio track, and chapter support. No server required.",
5
5
  "license": "MIT",
6
6
  "author": "Punyam Singh",