@lightbird/core 0.7.0 → 0.9.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
@@ -765,14 +765,32 @@ function isHlsUrl(url) {
765
765
  if (HLS_MIME_HINTS.some((hint) => lower.includes(hint))) return true;
766
766
  return lower.split(/[?#]/)[0].endsWith(".m3u8");
767
767
  }
768
+ function parseHlsCodec(codec) {
769
+ if (!codec) return null;
770
+ switch (codec.slice(0, 4).toLowerCase()) {
771
+ case "avc1":
772
+ return "H.264 (AVC)";
773
+ case "hvc1":
774
+ case "hev1":
775
+ return "H.265 (HEVC)";
776
+ case "vp09":
777
+ return "VP9";
778
+ case "av01":
779
+ return "AV1";
780
+ default:
781
+ return codec;
782
+ }
783
+ }
768
784
  var HLSPlayer = class {
769
785
  constructor(url) {
770
786
  this.hls = null;
787
+ this.hlsCtor = null;
771
788
  this.url = url;
772
789
  this.playerFile = { url, qualityLevels: [] };
773
790
  }
774
791
  async initialize(videoElement) {
775
792
  const { default: HlsCtor } = await import('hls.js');
793
+ this.hlsCtor = HlsCtor;
776
794
  if (!HlsCtor.isSupported()) {
777
795
  videoElement.src = this.url;
778
796
  return this.playerFile;
@@ -818,6 +836,50 @@ var HLSPlayer = class {
818
836
  setQualityLevel(levelIndex) {
819
837
  if (this.hls) this.hls.currentLevel = levelIndex;
820
838
  }
839
+ /**
840
+ * HLS-specific metadata for the video info panel, derived from the active
841
+ * rendition. Returns an empty object before the manifest loads or on the
842
+ * native-HLS path (where hls.js is never instantiated).
843
+ */
844
+ getMetadata() {
845
+ const hls = this.hls;
846
+ if (!hls) return {};
847
+ const levels = hls.levels ?? [];
848
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
849
+ const active = levels[activeIndex];
850
+ return {
851
+ container: "HLS",
852
+ videoCodec: parseHlsCodec(active?.videoCodec),
853
+ videoBitrate: active?.bitrate ?? null,
854
+ streamRenditions: levels.length,
855
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
856
+ index,
857
+ codec: track.audioCodec ?? null,
858
+ channels: null,
859
+ sampleRate: null,
860
+ language: track.lang ?? null,
861
+ bitrate: null
862
+ }))
863
+ };
864
+ }
865
+ /**
866
+ * Subscribe to HLS events that change the info-panel metadata (manifest
867
+ * parsed, quality level switched, audio tracks updated). Returns an
868
+ * unsubscribe function. A no-op on the native-HLS path.
869
+ */
870
+ onMetadataChange(callback) {
871
+ const hls = this.hls;
872
+ const HlsCtor = this.hlsCtor;
873
+ if (!hls || !HlsCtor) return () => {
874
+ };
875
+ const events = [
876
+ HlsCtor.Events.MANIFEST_PARSED,
877
+ HlsCtor.Events.LEVEL_SWITCHED,
878
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
879
+ ];
880
+ events.forEach((event) => hls.on(event, callback));
881
+ return () => events.forEach((event) => hls.off(event, callback));
882
+ }
821
883
  destroy() {
822
884
  if (this.hls) {
823
885
  this.hls.destroy();
@@ -1614,6 +1676,36 @@ async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
1614
1676
  });
1615
1677
  }
1616
1678
 
1679
+ // src/utils/frame-export.ts
1680
+ function exportVideoFrame(videoEl, options = {}) {
1681
+ const { type = "image/png", quality, filter } = options;
1682
+ const width = videoEl.videoWidth;
1683
+ const height = videoEl.videoHeight;
1684
+ if (!width || !height) return null;
1685
+ const canvas = document.createElement("canvas");
1686
+ canvas.width = width;
1687
+ canvas.height = height;
1688
+ const ctx = canvas.getContext("2d");
1689
+ if (!ctx) return null;
1690
+ try {
1691
+ if (filter) ctx.filter = filter;
1692
+ ctx.drawImage(videoEl, 0, 0, width, height);
1693
+ return canvas.toDataURL(type, quality);
1694
+ } catch {
1695
+ return null;
1696
+ }
1697
+ }
1698
+ function downloadDataUrl(dataUrl, filename) {
1699
+ const a = document.createElement("a");
1700
+ a.href = dataUrl;
1701
+ a.download = filename;
1702
+ a.click();
1703
+ }
1704
+ function frameExportFilename(extension = "png") {
1705
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
1706
+ return `lightbird-screenshot-${stamp}.${extension}`;
1707
+ }
1708
+
1617
1709
  // src/utils/keyboard-shortcuts.ts
1618
1710
  var DEFAULT_SHORTCUTS = [
1619
1711
  { action: "play-pause", label: "Play / Pause", defaultKey: " ", key: " " },
@@ -1773,9 +1865,12 @@ exports.configureLightBird = configureLightBird;
1773
1865
  exports.createOffsetVttUrl = createOffsetVttUrl;
1774
1866
  exports.createVideoPlayer = createVideoPlayer;
1775
1867
  exports.destroyWebTorrentClient = destroyWebTorrentClient;
1868
+ exports.downloadDataUrl = downloadDataUrl;
1776
1869
  exports.exportPlaylist = exportPlaylist;
1870
+ exports.exportVideoFrame = exportVideoFrame;
1777
1871
  exports.extractNativeMetadata = extractNativeMetadata;
1778
1872
  exports.formatShortcutKey = formatShortcutKey;
1873
+ exports.frameExportFilename = frameExportFilename;
1779
1874
  exports.getFFmpeg = getFFmpeg;
1780
1875
  exports.getLanguageName = getLanguageName;
1781
1876
  exports.getVideoFiles = getVideoFiles;
@@ -1790,6 +1885,7 @@ exports.loadShortcuts = loadShortcuts;
1790
1885
  exports.matchesShortcut = matchesShortcut;
1791
1886
  exports.parseChaptersFromFFmpegLog = parseChaptersFromFFmpegLog;
1792
1887
  exports.parseChaptersFromVtt = parseChaptersFromVtt;
1888
+ exports.parseHlsCodec = parseHlsCodec;
1793
1889
  exports.parseM3U8 = parseM3U8;
1794
1890
  exports.parseMediaError = parseMediaError;
1795
1891
  exports.resetFFmpeg = resetFFmpeg;
package/dist/index.d.cts CHANGED
@@ -69,6 +69,8 @@ interface VideoMetadata {
69
69
  colorSpace: string | null;
70
70
  audioTracks: AudioTrackMeta[];
71
71
  subtitleTracks: SubtitleTrackMeta[];
72
+ /** Number of HLS/adaptive renditions, when playing an adaptive stream. */
73
+ streamRenditions?: number | null;
72
74
  }
73
75
  interface AudioTrackMeta {
74
76
  index: number;
@@ -196,16 +198,27 @@ interface VideoPlayer {
196
198
  * For all other players/paths this is already resolved when initialize() returns.
197
199
  */
198
200
  tracksReady?: Promise<void>;
201
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
202
+ getMetadata?(): Partial<VideoMetadata>;
203
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
204
+ onMetadataChange?(callback: () => void): () => void;
199
205
  }
200
206
  declare function createVideoPlayer(source: File | string, externalSubtitles?: File[], onProgress?: (progress: number) => void): VideoPlayer;
201
207
 
202
208
  /** True for HLS playlist URLs — a `.m3u8` path or an HLS MIME hint. */
203
209
  declare function isHlsUrl(url: string): boolean;
210
+ /**
211
+ * Map an hls.js MIME codec string (e.g. `avc1.640028`) to a human-friendly
212
+ * label by inspecting the leading four-character codec family. Unknown
213
+ * families fall back to the raw string; empty input yields `null`.
214
+ */
215
+ declare function parseHlsCodec(codec: string | null | undefined): string | null;
204
216
  /** Plays HLS (`.m3u8`) streams via hls.js, falling back to native HLS (Safari). */
205
217
  declare class HLSPlayer implements VideoPlayer {
206
218
  private readonly url;
207
219
  private readonly playerFile;
208
220
  private hls;
221
+ private hlsCtor;
209
222
  constructor(url: string);
210
223
  initialize(videoElement: HTMLVideoElement): Promise<HLSPlayerFile>;
211
224
  getAudioTracks(): AudioTrack[];
@@ -216,6 +229,18 @@ declare class HLSPlayer implements VideoPlayer {
216
229
  getQualityLevels(): QualityLevel[];
217
230
  /** Pins a quality level; `-1` restores automatic (ABR) selection. */
218
231
  setQualityLevel(levelIndex: number): void;
232
+ /**
233
+ * HLS-specific metadata for the video info panel, derived from the active
234
+ * rendition. Returns an empty object before the manifest loads or on the
235
+ * native-HLS path (where hls.js is never instantiated).
236
+ */
237
+ getMetadata(): Partial<VideoMetadata>;
238
+ /**
239
+ * Subscribe to HLS events that change the info-panel metadata (manifest
240
+ * parsed, quality level switched, audio tracks updated). Returns an
241
+ * unsubscribe function. A no-op on the native-HLS path.
242
+ */
243
+ onMetadataChange(callback: () => void): () => void;
219
244
  destroy(): void;
220
245
  static isCompatible(url: string): boolean;
221
246
  }
@@ -388,6 +413,51 @@ declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: nu
388
413
  */
389
414
  declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
390
415
 
416
+ /**
417
+ * Frame / screenshot export.
418
+ *
419
+ * Captures the currently displayed frame of a playing video element to an
420
+ * image and (optionally) triggers a browser download. Unlike the seek-hover
421
+ * preview in {@link ./video-thumbnail}, this grabs the frame *in place* at the
422
+ * video's native resolution without seeking, so it never disturbs playback.
423
+ */
424
+ interface ExportFrameOptions {
425
+ /** Output image MIME type. Defaults to `"image/png"`. */
426
+ type?: string;
427
+ /** Quality (0–1) for lossy formats such as jpeg/webp. Ignored for png. */
428
+ quality?: number;
429
+ /**
430
+ * CSS `filter` string to bake into the exported image (e.g. the
431
+ * brightness/contrast filters applied to the video element). When omitted,
432
+ * the raw frame is captured.
433
+ */
434
+ filter?: string;
435
+ }
436
+ /**
437
+ * Capture the current frame of a video element at its native resolution.
438
+ *
439
+ * @param videoEl - The video element to capture from.
440
+ * @param options - Output format and an optional CSS filter to bake in.
441
+ * @returns An image data URL, or `null` if capture failed — no 2d context, the
442
+ * video has no decoded dimensions yet, or the canvas is tainted by a
443
+ * cross-origin source.
444
+ */
445
+ declare function exportVideoFrame(videoEl: HTMLVideoElement, options?: ExportFrameOptions): string | null;
446
+ /**
447
+ * Trigger a browser download of a data URL by synthesizing an anchor click.
448
+ *
449
+ * @param dataUrl - The data (or object) URL to download.
450
+ * @param filename - The suggested filename for the saved file.
451
+ */
452
+ declare function downloadDataUrl(dataUrl: string, filename: string): void;
453
+ /**
454
+ * Build a timestamped default filename for an exported frame.
455
+ *
456
+ * @param extension - File extension without a leading dot. Defaults to `"png"`.
457
+ * @returns e.g. `lightbird-screenshot-2026-05-30T12-00-00-000Z.png`.
458
+ */
459
+ declare function frameExportFilename(extension?: string): string;
460
+
391
461
  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
462
  interface ShortcutBinding {
393
463
  action: ShortcutAction;
@@ -450,4 +520,4 @@ declare function resetFFmpeg(): void;
450
520
  */
451
521
  declare function getLanguageName(code?: string | null): string | undefined;
452
522
 
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 };
523
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, type ExportFrameOptions, 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, downloadDataUrl, exportPlaylist, exportVideoFrame, extractNativeMetadata, formatShortcutKey, frameExportFilename, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseHlsCodec, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.d.ts CHANGED
@@ -69,6 +69,8 @@ interface VideoMetadata {
69
69
  colorSpace: string | null;
70
70
  audioTracks: AudioTrackMeta[];
71
71
  subtitleTracks: SubtitleTrackMeta[];
72
+ /** Number of HLS/adaptive renditions, when playing an adaptive stream. */
73
+ streamRenditions?: number | null;
72
74
  }
73
75
  interface AudioTrackMeta {
74
76
  index: number;
@@ -196,16 +198,27 @@ interface VideoPlayer {
196
198
  * For all other players/paths this is already resolved when initialize() returns.
197
199
  */
198
200
  tracksReady?: Promise<void>;
201
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
202
+ getMetadata?(): Partial<VideoMetadata>;
203
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
204
+ onMetadataChange?(callback: () => void): () => void;
199
205
  }
200
206
  declare function createVideoPlayer(source: File | string, externalSubtitles?: File[], onProgress?: (progress: number) => void): VideoPlayer;
201
207
 
202
208
  /** True for HLS playlist URLs — a `.m3u8` path or an HLS MIME hint. */
203
209
  declare function isHlsUrl(url: string): boolean;
210
+ /**
211
+ * Map an hls.js MIME codec string (e.g. `avc1.640028`) to a human-friendly
212
+ * label by inspecting the leading four-character codec family. Unknown
213
+ * families fall back to the raw string; empty input yields `null`.
214
+ */
215
+ declare function parseHlsCodec(codec: string | null | undefined): string | null;
204
216
  /** Plays HLS (`.m3u8`) streams via hls.js, falling back to native HLS (Safari). */
205
217
  declare class HLSPlayer implements VideoPlayer {
206
218
  private readonly url;
207
219
  private readonly playerFile;
208
220
  private hls;
221
+ private hlsCtor;
209
222
  constructor(url: string);
210
223
  initialize(videoElement: HTMLVideoElement): Promise<HLSPlayerFile>;
211
224
  getAudioTracks(): AudioTrack[];
@@ -216,6 +229,18 @@ declare class HLSPlayer implements VideoPlayer {
216
229
  getQualityLevels(): QualityLevel[];
217
230
  /** Pins a quality level; `-1` restores automatic (ABR) selection. */
218
231
  setQualityLevel(levelIndex: number): void;
232
+ /**
233
+ * HLS-specific metadata for the video info panel, derived from the active
234
+ * rendition. Returns an empty object before the manifest loads or on the
235
+ * native-HLS path (where hls.js is never instantiated).
236
+ */
237
+ getMetadata(): Partial<VideoMetadata>;
238
+ /**
239
+ * Subscribe to HLS events that change the info-panel metadata (manifest
240
+ * parsed, quality level switched, audio tracks updated). Returns an
241
+ * unsubscribe function. A no-op on the native-HLS path.
242
+ */
243
+ onMetadataChange(callback: () => void): () => void;
219
244
  destroy(): void;
220
245
  static isCompatible(url: string): boolean;
221
246
  }
@@ -388,6 +413,51 @@ declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: nu
388
413
  */
389
414
  declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
390
415
 
416
+ /**
417
+ * Frame / screenshot export.
418
+ *
419
+ * Captures the currently displayed frame of a playing video element to an
420
+ * image and (optionally) triggers a browser download. Unlike the seek-hover
421
+ * preview in {@link ./video-thumbnail}, this grabs the frame *in place* at the
422
+ * video's native resolution without seeking, so it never disturbs playback.
423
+ */
424
+ interface ExportFrameOptions {
425
+ /** Output image MIME type. Defaults to `"image/png"`. */
426
+ type?: string;
427
+ /** Quality (0–1) for lossy formats such as jpeg/webp. Ignored for png. */
428
+ quality?: number;
429
+ /**
430
+ * CSS `filter` string to bake into the exported image (e.g. the
431
+ * brightness/contrast filters applied to the video element). When omitted,
432
+ * the raw frame is captured.
433
+ */
434
+ filter?: string;
435
+ }
436
+ /**
437
+ * Capture the current frame of a video element at its native resolution.
438
+ *
439
+ * @param videoEl - The video element to capture from.
440
+ * @param options - Output format and an optional CSS filter to bake in.
441
+ * @returns An image data URL, or `null` if capture failed — no 2d context, the
442
+ * video has no decoded dimensions yet, or the canvas is tainted by a
443
+ * cross-origin source.
444
+ */
445
+ declare function exportVideoFrame(videoEl: HTMLVideoElement, options?: ExportFrameOptions): string | null;
446
+ /**
447
+ * Trigger a browser download of a data URL by synthesizing an anchor click.
448
+ *
449
+ * @param dataUrl - The data (or object) URL to download.
450
+ * @param filename - The suggested filename for the saved file.
451
+ */
452
+ declare function downloadDataUrl(dataUrl: string, filename: string): void;
453
+ /**
454
+ * Build a timestamped default filename for an exported frame.
455
+ *
456
+ * @param extension - File extension without a leading dot. Defaults to `"png"`.
457
+ * @returns e.g. `lightbird-screenshot-2026-05-30T12-00-00-000Z.png`.
458
+ */
459
+ declare function frameExportFilename(extension?: string): string;
460
+
391
461
  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
462
  interface ShortcutBinding {
393
463
  action: ShortcutAction;
@@ -450,4 +520,4 @@ declare function resetFFmpeg(): void;
450
520
  */
451
521
  declare function getLanguageName(code?: string | null): string | undefined;
452
522
 
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 };
523
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, type ExportFrameOptions, 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, downloadDataUrl, exportPlaylist, exportVideoFrame, extractNativeMetadata, formatShortcutKey, frameExportFilename, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseHlsCodec, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.js CHANGED
@@ -762,14 +762,32 @@ function isHlsUrl(url) {
762
762
  if (HLS_MIME_HINTS.some((hint) => lower.includes(hint))) return true;
763
763
  return lower.split(/[?#]/)[0].endsWith(".m3u8");
764
764
  }
765
+ function parseHlsCodec(codec) {
766
+ if (!codec) return null;
767
+ switch (codec.slice(0, 4).toLowerCase()) {
768
+ case "avc1":
769
+ return "H.264 (AVC)";
770
+ case "hvc1":
771
+ case "hev1":
772
+ return "H.265 (HEVC)";
773
+ case "vp09":
774
+ return "VP9";
775
+ case "av01":
776
+ return "AV1";
777
+ default:
778
+ return codec;
779
+ }
780
+ }
765
781
  var HLSPlayer = class {
766
782
  constructor(url) {
767
783
  this.hls = null;
784
+ this.hlsCtor = null;
768
785
  this.url = url;
769
786
  this.playerFile = { url, qualityLevels: [] };
770
787
  }
771
788
  async initialize(videoElement) {
772
789
  const { default: HlsCtor } = await import('hls.js');
790
+ this.hlsCtor = HlsCtor;
773
791
  if (!HlsCtor.isSupported()) {
774
792
  videoElement.src = this.url;
775
793
  return this.playerFile;
@@ -815,6 +833,50 @@ var HLSPlayer = class {
815
833
  setQualityLevel(levelIndex) {
816
834
  if (this.hls) this.hls.currentLevel = levelIndex;
817
835
  }
836
+ /**
837
+ * HLS-specific metadata for the video info panel, derived from the active
838
+ * rendition. Returns an empty object before the manifest loads or on the
839
+ * native-HLS path (where hls.js is never instantiated).
840
+ */
841
+ getMetadata() {
842
+ const hls = this.hls;
843
+ if (!hls) return {};
844
+ const levels = hls.levels ?? [];
845
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
846
+ const active = levels[activeIndex];
847
+ return {
848
+ container: "HLS",
849
+ videoCodec: parseHlsCodec(active?.videoCodec),
850
+ videoBitrate: active?.bitrate ?? null,
851
+ streamRenditions: levels.length,
852
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
853
+ index,
854
+ codec: track.audioCodec ?? null,
855
+ channels: null,
856
+ sampleRate: null,
857
+ language: track.lang ?? null,
858
+ bitrate: null
859
+ }))
860
+ };
861
+ }
862
+ /**
863
+ * Subscribe to HLS events that change the info-panel metadata (manifest
864
+ * parsed, quality level switched, audio tracks updated). Returns an
865
+ * unsubscribe function. A no-op on the native-HLS path.
866
+ */
867
+ onMetadataChange(callback) {
868
+ const hls = this.hls;
869
+ const HlsCtor = this.hlsCtor;
870
+ if (!hls || !HlsCtor) return () => {
871
+ };
872
+ const events = [
873
+ HlsCtor.Events.MANIFEST_PARSED,
874
+ HlsCtor.Events.LEVEL_SWITCHED,
875
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
876
+ ];
877
+ events.forEach((event) => hls.on(event, callback));
878
+ return () => events.forEach((event) => hls.off(event, callback));
879
+ }
818
880
  destroy() {
819
881
  if (this.hls) {
820
882
  this.hls.destroy();
@@ -1611,6 +1673,36 @@ async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
1611
1673
  });
1612
1674
  }
1613
1675
 
1676
+ // src/utils/frame-export.ts
1677
+ function exportVideoFrame(videoEl, options = {}) {
1678
+ const { type = "image/png", quality, filter } = options;
1679
+ const width = videoEl.videoWidth;
1680
+ const height = videoEl.videoHeight;
1681
+ if (!width || !height) return null;
1682
+ const canvas = document.createElement("canvas");
1683
+ canvas.width = width;
1684
+ canvas.height = height;
1685
+ const ctx = canvas.getContext("2d");
1686
+ if (!ctx) return null;
1687
+ try {
1688
+ if (filter) ctx.filter = filter;
1689
+ ctx.drawImage(videoEl, 0, 0, width, height);
1690
+ return canvas.toDataURL(type, quality);
1691
+ } catch {
1692
+ return null;
1693
+ }
1694
+ }
1695
+ function downloadDataUrl(dataUrl, filename) {
1696
+ const a = document.createElement("a");
1697
+ a.href = dataUrl;
1698
+ a.download = filename;
1699
+ a.click();
1700
+ }
1701
+ function frameExportFilename(extension = "png") {
1702
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
1703
+ return `lightbird-screenshot-${stamp}.${extension}`;
1704
+ }
1705
+
1614
1706
  // src/utils/keyboard-shortcuts.ts
1615
1707
  var DEFAULT_SHORTCUTS = [
1616
1708
  { action: "play-pause", label: "Play / Pause", defaultKey: " ", key: " " },
@@ -1749,4 +1841,4 @@ function resetFFmpeg() {
1749
1841
  loading = null;
1750
1842
  }
1751
1843
 
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 };
1844
+ 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, downloadDataUrl, exportPlaylist, exportVideoFrame, extractNativeMetadata, formatShortcutKey, frameExportFilename, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseHlsCodec, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
@@ -81,6 +81,8 @@ interface VideoMetadata {
81
81
  colorSpace: string | null;
82
82
  audioTracks: AudioTrackMeta[];
83
83
  subtitleTracks: SubtitleTrackMeta[];
84
+ /** Number of HLS/adaptive renditions, when playing an adaptive stream. */
85
+ streamRenditions?: number | null;
84
86
  }
85
87
  interface AudioTrackMeta {
86
88
  index: number;
@@ -264,6 +266,10 @@ interface VideoPlayer {
264
266
  * For all other players/paths this is already resolved when initialize() returns.
265
267
  */
266
268
  tracksReady?: Promise<void>;
269
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
270
+ getMetadata?(): Partial<VideoMetadata>;
271
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
272
+ onMetadataChange?(callback: () => void): () => void;
267
273
  }
268
274
 
269
275
  declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: RefObject<VideoPlayer | null>): {
@@ -81,6 +81,8 @@ interface VideoMetadata {
81
81
  colorSpace: string | null;
82
82
  audioTracks: AudioTrackMeta[];
83
83
  subtitleTracks: SubtitleTrackMeta[];
84
+ /** Number of HLS/adaptive renditions, when playing an adaptive stream. */
85
+ streamRenditions?: number | null;
84
86
  }
85
87
  interface AudioTrackMeta {
86
88
  index: number;
@@ -264,6 +266,10 @@ interface VideoPlayer {
264
266
  * For all other players/paths this is already resolved when initialize() returns.
265
267
  */
266
268
  tracksReady?: Promise<void>;
269
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
270
+ getMetadata?(): Partial<VideoMetadata>;
271
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
272
+ onMetadataChange?(callback: () => void): () => void;
267
273
  }
268
274
 
269
275
  declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: RefObject<VideoPlayer | null>): {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightbird/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",