@lightbird/core 0.8.0 → 0.10.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,57 @@ var HLSPlayer = class {
818
836
  setQualityLevel(levelIndex) {
819
837
  if (this.hls) this.hls.currentLevel = levelIndex;
820
838
  }
839
+ /**
840
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
841
+ * before the manifest loads / on the native-HLS path.
842
+ */
843
+ getCurrentLevel() {
844
+ return this.hls?.currentLevel ?? -1;
845
+ }
846
+ /**
847
+ * HLS-specific metadata for the video info panel, derived from the active
848
+ * rendition. Returns an empty object before the manifest loads or on the
849
+ * native-HLS path (where hls.js is never instantiated).
850
+ */
851
+ getMetadata() {
852
+ const hls = this.hls;
853
+ if (!hls) return {};
854
+ const levels = hls.levels ?? [];
855
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
856
+ const active = levels[activeIndex];
857
+ return {
858
+ container: "HLS",
859
+ videoCodec: parseHlsCodec(active?.videoCodec),
860
+ videoBitrate: active?.bitrate ?? null,
861
+ streamRenditions: levels.length,
862
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
863
+ index,
864
+ codec: track.audioCodec ?? null,
865
+ channels: null,
866
+ sampleRate: null,
867
+ language: track.lang ?? null,
868
+ bitrate: null
869
+ }))
870
+ };
871
+ }
872
+ /**
873
+ * Subscribe to HLS events that change the info-panel metadata (manifest
874
+ * parsed, quality level switched, audio tracks updated). Returns an
875
+ * unsubscribe function. A no-op on the native-HLS path.
876
+ */
877
+ onMetadataChange(callback) {
878
+ const hls = this.hls;
879
+ const HlsCtor = this.hlsCtor;
880
+ if (!hls || !HlsCtor) return () => {
881
+ };
882
+ const events = [
883
+ HlsCtor.Events.MANIFEST_PARSED,
884
+ HlsCtor.Events.LEVEL_SWITCHED,
885
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
886
+ ];
887
+ events.forEach((event) => hls.on(event, callback));
888
+ return () => events.forEach((event) => hls.off(event, callback));
889
+ }
821
890
  destroy() {
822
891
  if (this.hls) {
823
892
  this.hls.destroy();
@@ -1823,6 +1892,7 @@ exports.loadShortcuts = loadShortcuts;
1823
1892
  exports.matchesShortcut = matchesShortcut;
1824
1893
  exports.parseChaptersFromFFmpegLog = parseChaptersFromFFmpegLog;
1825
1894
  exports.parseChaptersFromVtt = parseChaptersFromVtt;
1895
+ exports.parseHlsCodec = parseHlsCodec;
1826
1896
  exports.parseM3U8 = parseM3U8;
1827
1897
  exports.parseMediaError = parseMediaError;
1828
1898
  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,23 @@ 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
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
234
+ * before the manifest loads / on the native-HLS path.
235
+ */
236
+ getCurrentLevel(): number;
237
+ /**
238
+ * HLS-specific metadata for the video info panel, derived from the active
239
+ * rendition. Returns an empty object before the manifest loads or on the
240
+ * native-HLS path (where hls.js is never instantiated).
241
+ */
242
+ getMetadata(): Partial<VideoMetadata>;
243
+ /**
244
+ * Subscribe to HLS events that change the info-panel metadata (manifest
245
+ * parsed, quality level switched, audio tracks updated). Returns an
246
+ * unsubscribe function. A no-op on the native-HLS path.
247
+ */
248
+ onMetadataChange(callback: () => void): () => void;
219
249
  destroy(): void;
220
250
  static isCompatible(url: string): boolean;
221
251
  }
@@ -495,4 +525,4 @@ declare function resetFFmpeg(): void;
495
525
  */
496
526
  declare function getLanguageName(code?: string | null): string | undefined;
497
527
 
498
- 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, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
528
+ 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,23 @@ 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
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
234
+ * before the manifest loads / on the native-HLS path.
235
+ */
236
+ getCurrentLevel(): number;
237
+ /**
238
+ * HLS-specific metadata for the video info panel, derived from the active
239
+ * rendition. Returns an empty object before the manifest loads or on the
240
+ * native-HLS path (where hls.js is never instantiated).
241
+ */
242
+ getMetadata(): Partial<VideoMetadata>;
243
+ /**
244
+ * Subscribe to HLS events that change the info-panel metadata (manifest
245
+ * parsed, quality level switched, audio tracks updated). Returns an
246
+ * unsubscribe function. A no-op on the native-HLS path.
247
+ */
248
+ onMetadataChange(callback: () => void): () => void;
219
249
  destroy(): void;
220
250
  static isCompatible(url: string): boolean;
221
251
  }
@@ -495,4 +525,4 @@ declare function resetFFmpeg(): void;
495
525
  */
496
526
  declare function getLanguageName(code?: string | null): string | undefined;
497
527
 
498
- 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, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
528
+ 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,57 @@ var HLSPlayer = class {
815
833
  setQualityLevel(levelIndex) {
816
834
  if (this.hls) this.hls.currentLevel = levelIndex;
817
835
  }
836
+ /**
837
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
838
+ * before the manifest loads / on the native-HLS path.
839
+ */
840
+ getCurrentLevel() {
841
+ return this.hls?.currentLevel ?? -1;
842
+ }
843
+ /**
844
+ * HLS-specific metadata for the video info panel, derived from the active
845
+ * rendition. Returns an empty object before the manifest loads or on the
846
+ * native-HLS path (where hls.js is never instantiated).
847
+ */
848
+ getMetadata() {
849
+ const hls = this.hls;
850
+ if (!hls) return {};
851
+ const levels = hls.levels ?? [];
852
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
853
+ const active = levels[activeIndex];
854
+ return {
855
+ container: "HLS",
856
+ videoCodec: parseHlsCodec(active?.videoCodec),
857
+ videoBitrate: active?.bitrate ?? null,
858
+ streamRenditions: levels.length,
859
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
860
+ index,
861
+ codec: track.audioCodec ?? null,
862
+ channels: null,
863
+ sampleRate: null,
864
+ language: track.lang ?? null,
865
+ bitrate: null
866
+ }))
867
+ };
868
+ }
869
+ /**
870
+ * Subscribe to HLS events that change the info-panel metadata (manifest
871
+ * parsed, quality level switched, audio tracks updated). Returns an
872
+ * unsubscribe function. A no-op on the native-HLS path.
873
+ */
874
+ onMetadataChange(callback) {
875
+ const hls = this.hls;
876
+ const HlsCtor = this.hlsCtor;
877
+ if (!hls || !HlsCtor) return () => {
878
+ };
879
+ const events = [
880
+ HlsCtor.Events.MANIFEST_PARSED,
881
+ HlsCtor.Events.LEVEL_SWITCHED,
882
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
883
+ ];
884
+ events.forEach((event) => hls.on(event, callback));
885
+ return () => events.forEach((event) => hls.off(event, callback));
886
+ }
818
887
  destroy() {
819
888
  if (this.hls) {
820
889
  this.hls.destroy();
@@ -1779,4 +1848,4 @@ function resetFFmpeg() {
1779
1848
  loading = null;
1780
1849
  }
1781
1850
 
1782
- 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, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
1851
+ 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 };
@@ -836,6 +836,177 @@ function useVideoInfo(videoRef, currentFile) {
836
836
  }, []);
837
837
  return { metadata, enrichMetadata };
838
838
  }
839
+
840
+ // src/players/hls-player.ts
841
+ var HLS_MIME_HINTS = ["application/x-mpegurl", "application/vnd.apple.mpegurl"];
842
+ function isHlsUrl(url) {
843
+ if (typeof url !== "string" || url.length === 0) return false;
844
+ const lower = url.toLowerCase();
845
+ if (HLS_MIME_HINTS.some((hint) => lower.includes(hint))) return true;
846
+ return lower.split(/[?#]/)[0].endsWith(".m3u8");
847
+ }
848
+ function parseHlsCodec(codec) {
849
+ if (!codec) return null;
850
+ switch (codec.slice(0, 4).toLowerCase()) {
851
+ case "avc1":
852
+ return "H.264 (AVC)";
853
+ case "hvc1":
854
+ case "hev1":
855
+ return "H.265 (HEVC)";
856
+ case "vp09":
857
+ return "VP9";
858
+ case "av01":
859
+ return "AV1";
860
+ default:
861
+ return codec;
862
+ }
863
+ }
864
+ var HLSPlayer = class {
865
+ constructor(url) {
866
+ this.hls = null;
867
+ this.hlsCtor = null;
868
+ this.url = url;
869
+ this.playerFile = { url, qualityLevels: [] };
870
+ }
871
+ async initialize(videoElement) {
872
+ const { default: HlsCtor } = await import('hls.js');
873
+ this.hlsCtor = HlsCtor;
874
+ if (!HlsCtor.isSupported()) {
875
+ videoElement.src = this.url;
876
+ return this.playerFile;
877
+ }
878
+ const hls = new HlsCtor();
879
+ this.hls = hls;
880
+ hls.loadSource(this.url);
881
+ hls.attachMedia(videoElement);
882
+ return this.playerFile;
883
+ }
884
+ getAudioTracks() {
885
+ if (!this.hls) return [];
886
+ return this.hls.audioTracks.map((track) => ({
887
+ id: String(track.id),
888
+ name: track.name,
889
+ lang: track.lang || "unknown"
890
+ }));
891
+ }
892
+ getSubtitles() {
893
+ return [];
894
+ }
895
+ switchAudioTrack(trackId) {
896
+ if (this.hls) {
897
+ const id = Number(trackId);
898
+ if (!Number.isNaN(id)) this.hls.audioTrack = id;
899
+ }
900
+ return Promise.resolve();
901
+ }
902
+ switchSubtitle() {
903
+ return Promise.resolve();
904
+ }
905
+ /** Quality renditions — not on `VideoPlayer`; read directly by the quality hook. */
906
+ getQualityLevels() {
907
+ if (!this.hls) return [];
908
+ return this.hls.levels.map((level, index) => ({
909
+ index,
910
+ height: level.height,
911
+ bitrate: level.bitrate,
912
+ name: level.name || (level.height > 0 ? `${level.height}p` : `Level ${index + 1}`)
913
+ }));
914
+ }
915
+ /** Pins a quality level; `-1` restores automatic (ABR) selection. */
916
+ setQualityLevel(levelIndex) {
917
+ if (this.hls) this.hls.currentLevel = levelIndex;
918
+ }
919
+ /**
920
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
921
+ * before the manifest loads / on the native-HLS path.
922
+ */
923
+ getCurrentLevel() {
924
+ return this.hls?.currentLevel ?? -1;
925
+ }
926
+ /**
927
+ * HLS-specific metadata for the video info panel, derived from the active
928
+ * rendition. Returns an empty object before the manifest loads or on the
929
+ * native-HLS path (where hls.js is never instantiated).
930
+ */
931
+ getMetadata() {
932
+ const hls = this.hls;
933
+ if (!hls) return {};
934
+ const levels = hls.levels ?? [];
935
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
936
+ const active = levels[activeIndex];
937
+ return {
938
+ container: "HLS",
939
+ videoCodec: parseHlsCodec(active?.videoCodec),
940
+ videoBitrate: active?.bitrate ?? null,
941
+ streamRenditions: levels.length,
942
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
943
+ index,
944
+ codec: track.audioCodec ?? null,
945
+ channels: null,
946
+ sampleRate: null,
947
+ language: track.lang ?? null,
948
+ bitrate: null
949
+ }))
950
+ };
951
+ }
952
+ /**
953
+ * Subscribe to HLS events that change the info-panel metadata (manifest
954
+ * parsed, quality level switched, audio tracks updated). Returns an
955
+ * unsubscribe function. A no-op on the native-HLS path.
956
+ */
957
+ onMetadataChange(callback) {
958
+ const hls = this.hls;
959
+ const HlsCtor = this.hlsCtor;
960
+ if (!hls || !HlsCtor) return () => {
961
+ };
962
+ const events = [
963
+ HlsCtor.Events.MANIFEST_PARSED,
964
+ HlsCtor.Events.LEVEL_SWITCHED,
965
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
966
+ ];
967
+ events.forEach((event) => hls.on(event, callback));
968
+ return () => events.forEach((event) => hls.off(event, callback));
969
+ }
970
+ destroy() {
971
+ if (this.hls) {
972
+ this.hls.destroy();
973
+ this.hls = null;
974
+ }
975
+ }
976
+ static isCompatible(url) {
977
+ return isHlsUrl(url);
978
+ }
979
+ };
980
+
981
+ // src/react/use-hls-quality.ts
982
+ function useHlsQuality(playerRef) {
983
+ const [qualityLevels, setQualityLevels] = react.useState([]);
984
+ const [currentLevel, setCurrentLevel] = react.useState(-1);
985
+ const player = playerRef.current;
986
+ react.useEffect(() => {
987
+ if (!(player instanceof HLSPlayer)) {
988
+ setQualityLevels([]);
989
+ setCurrentLevel(-1);
990
+ return;
991
+ }
992
+ const refresh = () => {
993
+ setQualityLevels(player.getQualityLevels());
994
+ setCurrentLevel(player.getCurrentLevel());
995
+ };
996
+ refresh();
997
+ return player.onMetadataChange(refresh);
998
+ }, [player]);
999
+ const setLevel = react.useCallback(
1000
+ (idx) => {
1001
+ const current = playerRef.current;
1002
+ if (!(current instanceof HLSPlayer)) return;
1003
+ current.setQualityLevel(idx);
1004
+ setCurrentLevel(idx);
1005
+ },
1006
+ [playerRef]
1007
+ );
1008
+ return { qualityLevels, currentLevel, setLevel };
1009
+ }
839
1010
  function useMediaSession(options) {
840
1011
  const { title, artwork, onPlay, onPause, onNext, onPrev, onSeekForward, onSeekBackward } = options;
841
1012
  react.useEffect(() => {
@@ -1523,6 +1694,7 @@ function useTouchGestures(targetRef, handlers, options = {}) {
1523
1694
  exports.useABLoop = useABLoop;
1524
1695
  exports.useChapters = useChapters;
1525
1696
  exports.useFullscreen = useFullscreen;
1697
+ exports.useHlsQuality = useHlsQuality;
1526
1698
  exports.useKeyboardShortcuts = useKeyboardShortcuts;
1527
1699
  exports.useMagnet = useMagnet;
1528
1700
  exports.useMediaSession = useMediaSession;
@@ -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;
@@ -219,18 +221,6 @@ declare function useVideoInfo(videoRef: RefObject<HTMLVideoElement | null>, curr
219
221
  enrichMetadata: (extra: Partial<VideoMetadata>) => void;
220
222
  };
221
223
 
222
- interface UseMediaSessionOptions {
223
- title: string | null;
224
- artwork?: string | null;
225
- onPlay: () => void;
226
- onPause: () => void;
227
- onNext: () => void;
228
- onPrev: () => void;
229
- onSeekForward: () => void;
230
- onSeekBackward: () => void;
231
- }
232
- declare function useMediaSession(options: UseMediaSessionOptions): void;
233
-
234
224
  interface SimplePlayerFile {
235
225
  name: string;
236
226
  file: File;
@@ -264,7 +254,42 @@ interface VideoPlayer {
264
254
  * For all other players/paths this is already resolved when initialize() returns.
265
255
  */
266
256
  tracksReady?: Promise<void>;
257
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
258
+ getMetadata?(): Partial<VideoMetadata>;
259
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
260
+ onMetadataChange?(callback: () => void): () => void;
261
+ }
262
+
263
+ interface UseHlsQualityReturn {
264
+ /** Available renditions. Empty unless an HLS stream with levels is loaded. */
265
+ qualityLevels: QualityLevel[];
266
+ /** Index of the active level, or -1 for automatic (ABR) selection. */
267
+ currentLevel: number;
268
+ /** Switch to a level by index (-1 = auto). No-op when not playing HLS. */
269
+ setLevel: (idx: number) => void;
267
270
  }
271
+ /**
272
+ * Exposes the quality levels of the currently loaded HLS stream and lets the
273
+ * caller switch between them. Returns an empty list and a no-op `setLevel` when
274
+ * the player is not an {@link HLSPlayer}.
275
+ *
276
+ * The list and the active level are kept in sync via
277
+ * {@link HLSPlayer.onMetadataChange}, which fires on manifest parse and on
278
+ * every `LEVEL_SWITCHED` event.
279
+ */
280
+ declare function useHlsQuality(playerRef: RefObject<VideoPlayer | null>): UseHlsQualityReturn;
281
+
282
+ interface UseMediaSessionOptions {
283
+ title: string | null;
284
+ artwork?: string | null;
285
+ onPlay: () => void;
286
+ onPause: () => void;
287
+ onNext: () => void;
288
+ onPrev: () => void;
289
+ onSeekForward: () => void;
290
+ onSeekBackward: () => void;
291
+ }
292
+ declare function useMediaSession(options: UseMediaSessionOptions): void;
268
293
 
269
294
  declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: RefObject<VideoPlayer | null>): {
270
295
  chapters: Chapter[];
@@ -398,4 +423,4 @@ interface TouchGesturesState {
398
423
  */
399
424
  declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
400
425
 
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 };
426
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseHlsQualityReturn, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useHlsQuality, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -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;
@@ -219,18 +221,6 @@ declare function useVideoInfo(videoRef: RefObject<HTMLVideoElement | null>, curr
219
221
  enrichMetadata: (extra: Partial<VideoMetadata>) => void;
220
222
  };
221
223
 
222
- interface UseMediaSessionOptions {
223
- title: string | null;
224
- artwork?: string | null;
225
- onPlay: () => void;
226
- onPause: () => void;
227
- onNext: () => void;
228
- onPrev: () => void;
229
- onSeekForward: () => void;
230
- onSeekBackward: () => void;
231
- }
232
- declare function useMediaSession(options: UseMediaSessionOptions): void;
233
-
234
224
  interface SimplePlayerFile {
235
225
  name: string;
236
226
  file: File;
@@ -264,7 +254,42 @@ interface VideoPlayer {
264
254
  * For all other players/paths this is already resolved when initialize() returns.
265
255
  */
266
256
  tracksReady?: Promise<void>;
257
+ /** HLS-only: metadata derived from the active rendition (see HLSPlayer). */
258
+ getMetadata?(): Partial<VideoMetadata>;
259
+ /** HLS-only: subscribe to stream events that change metadata. Returns an unsubscribe fn. */
260
+ onMetadataChange?(callback: () => void): () => void;
261
+ }
262
+
263
+ interface UseHlsQualityReturn {
264
+ /** Available renditions. Empty unless an HLS stream with levels is loaded. */
265
+ qualityLevels: QualityLevel[];
266
+ /** Index of the active level, or -1 for automatic (ABR) selection. */
267
+ currentLevel: number;
268
+ /** Switch to a level by index (-1 = auto). No-op when not playing HLS. */
269
+ setLevel: (idx: number) => void;
267
270
  }
271
+ /**
272
+ * Exposes the quality levels of the currently loaded HLS stream and lets the
273
+ * caller switch between them. Returns an empty list and a no-op `setLevel` when
274
+ * the player is not an {@link HLSPlayer}.
275
+ *
276
+ * The list and the active level are kept in sync via
277
+ * {@link HLSPlayer.onMetadataChange}, which fires on manifest parse and on
278
+ * every `LEVEL_SWITCHED` event.
279
+ */
280
+ declare function useHlsQuality(playerRef: RefObject<VideoPlayer | null>): UseHlsQualityReturn;
281
+
282
+ interface UseMediaSessionOptions {
283
+ title: string | null;
284
+ artwork?: string | null;
285
+ onPlay: () => void;
286
+ onPause: () => void;
287
+ onNext: () => void;
288
+ onPrev: () => void;
289
+ onSeekForward: () => void;
290
+ onSeekBackward: () => void;
291
+ }
292
+ declare function useMediaSession(options: UseMediaSessionOptions): void;
268
293
 
269
294
  declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: RefObject<VideoPlayer | null>): {
270
295
  chapters: Chapter[];
@@ -398,4 +423,4 @@ interface TouchGesturesState {
398
423
  */
399
424
  declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
400
425
 
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 };
426
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseHlsQualityReturn, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useHlsQuality, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -834,6 +834,177 @@ function useVideoInfo(videoRef, currentFile) {
834
834
  }, []);
835
835
  return { metadata, enrichMetadata };
836
836
  }
837
+
838
+ // src/players/hls-player.ts
839
+ var HLS_MIME_HINTS = ["application/x-mpegurl", "application/vnd.apple.mpegurl"];
840
+ function isHlsUrl(url) {
841
+ if (typeof url !== "string" || url.length === 0) return false;
842
+ const lower = url.toLowerCase();
843
+ if (HLS_MIME_HINTS.some((hint) => lower.includes(hint))) return true;
844
+ return lower.split(/[?#]/)[0].endsWith(".m3u8");
845
+ }
846
+ function parseHlsCodec(codec) {
847
+ if (!codec) return null;
848
+ switch (codec.slice(0, 4).toLowerCase()) {
849
+ case "avc1":
850
+ return "H.264 (AVC)";
851
+ case "hvc1":
852
+ case "hev1":
853
+ return "H.265 (HEVC)";
854
+ case "vp09":
855
+ return "VP9";
856
+ case "av01":
857
+ return "AV1";
858
+ default:
859
+ return codec;
860
+ }
861
+ }
862
+ var HLSPlayer = class {
863
+ constructor(url) {
864
+ this.hls = null;
865
+ this.hlsCtor = null;
866
+ this.url = url;
867
+ this.playerFile = { url, qualityLevels: [] };
868
+ }
869
+ async initialize(videoElement) {
870
+ const { default: HlsCtor } = await import('hls.js');
871
+ this.hlsCtor = HlsCtor;
872
+ if (!HlsCtor.isSupported()) {
873
+ videoElement.src = this.url;
874
+ return this.playerFile;
875
+ }
876
+ const hls = new HlsCtor();
877
+ this.hls = hls;
878
+ hls.loadSource(this.url);
879
+ hls.attachMedia(videoElement);
880
+ return this.playerFile;
881
+ }
882
+ getAudioTracks() {
883
+ if (!this.hls) return [];
884
+ return this.hls.audioTracks.map((track) => ({
885
+ id: String(track.id),
886
+ name: track.name,
887
+ lang: track.lang || "unknown"
888
+ }));
889
+ }
890
+ getSubtitles() {
891
+ return [];
892
+ }
893
+ switchAudioTrack(trackId) {
894
+ if (this.hls) {
895
+ const id = Number(trackId);
896
+ if (!Number.isNaN(id)) this.hls.audioTrack = id;
897
+ }
898
+ return Promise.resolve();
899
+ }
900
+ switchSubtitle() {
901
+ return Promise.resolve();
902
+ }
903
+ /** Quality renditions — not on `VideoPlayer`; read directly by the quality hook. */
904
+ getQualityLevels() {
905
+ if (!this.hls) return [];
906
+ return this.hls.levels.map((level, index) => ({
907
+ index,
908
+ height: level.height,
909
+ bitrate: level.bitrate,
910
+ name: level.name || (level.height > 0 ? `${level.height}p` : `Level ${index + 1}`)
911
+ }));
912
+ }
913
+ /** Pins a quality level; `-1` restores automatic (ABR) selection. */
914
+ setQualityLevel(levelIndex) {
915
+ if (this.hls) this.hls.currentLevel = levelIndex;
916
+ }
917
+ /**
918
+ * Index of the active rendition, or `-1` for automatic (ABR) selection /
919
+ * before the manifest loads / on the native-HLS path.
920
+ */
921
+ getCurrentLevel() {
922
+ return this.hls?.currentLevel ?? -1;
923
+ }
924
+ /**
925
+ * HLS-specific metadata for the video info panel, derived from the active
926
+ * rendition. Returns an empty object before the manifest loads or on the
927
+ * native-HLS path (where hls.js is never instantiated).
928
+ */
929
+ getMetadata() {
930
+ const hls = this.hls;
931
+ if (!hls) return {};
932
+ const levels = hls.levels ?? [];
933
+ const activeIndex = hls.currentLevel >= 0 && hls.currentLevel < levels.length ? hls.currentLevel : 0;
934
+ const active = levels[activeIndex];
935
+ return {
936
+ container: "HLS",
937
+ videoCodec: parseHlsCodec(active?.videoCodec),
938
+ videoBitrate: active?.bitrate ?? null,
939
+ streamRenditions: levels.length,
940
+ audioTracks: (hls.audioTracks ?? []).map((track, index) => ({
941
+ index,
942
+ codec: track.audioCodec ?? null,
943
+ channels: null,
944
+ sampleRate: null,
945
+ language: track.lang ?? null,
946
+ bitrate: null
947
+ }))
948
+ };
949
+ }
950
+ /**
951
+ * Subscribe to HLS events that change the info-panel metadata (manifest
952
+ * parsed, quality level switched, audio tracks updated). Returns an
953
+ * unsubscribe function. A no-op on the native-HLS path.
954
+ */
955
+ onMetadataChange(callback) {
956
+ const hls = this.hls;
957
+ const HlsCtor = this.hlsCtor;
958
+ if (!hls || !HlsCtor) return () => {
959
+ };
960
+ const events = [
961
+ HlsCtor.Events.MANIFEST_PARSED,
962
+ HlsCtor.Events.LEVEL_SWITCHED,
963
+ HlsCtor.Events.AUDIO_TRACKS_UPDATED
964
+ ];
965
+ events.forEach((event) => hls.on(event, callback));
966
+ return () => events.forEach((event) => hls.off(event, callback));
967
+ }
968
+ destroy() {
969
+ if (this.hls) {
970
+ this.hls.destroy();
971
+ this.hls = null;
972
+ }
973
+ }
974
+ static isCompatible(url) {
975
+ return isHlsUrl(url);
976
+ }
977
+ };
978
+
979
+ // src/react/use-hls-quality.ts
980
+ function useHlsQuality(playerRef) {
981
+ const [qualityLevels, setQualityLevels] = useState([]);
982
+ const [currentLevel, setCurrentLevel] = useState(-1);
983
+ const player = playerRef.current;
984
+ useEffect(() => {
985
+ if (!(player instanceof HLSPlayer)) {
986
+ setQualityLevels([]);
987
+ setCurrentLevel(-1);
988
+ return;
989
+ }
990
+ const refresh = () => {
991
+ setQualityLevels(player.getQualityLevels());
992
+ setCurrentLevel(player.getCurrentLevel());
993
+ };
994
+ refresh();
995
+ return player.onMetadataChange(refresh);
996
+ }, [player]);
997
+ const setLevel = useCallback(
998
+ (idx) => {
999
+ const current = playerRef.current;
1000
+ if (!(current instanceof HLSPlayer)) return;
1001
+ current.setQualityLevel(idx);
1002
+ setCurrentLevel(idx);
1003
+ },
1004
+ [playerRef]
1005
+ );
1006
+ return { qualityLevels, currentLevel, setLevel };
1007
+ }
837
1008
  function useMediaSession(options) {
838
1009
  const { title, artwork, onPlay, onPause, onNext, onPrev, onSeekForward, onSeekBackward } = options;
839
1010
  useEffect(() => {
@@ -1518,4 +1689,4 @@ function useTouchGestures(targetRef, handlers, options = {}) {
1518
1689
  return { feedback };
1519
1690
  }
1520
1691
 
1521
- export { useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSmoothProgress, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
1692
+ export { useABLoop, useChapters, useFullscreen, useHlsQuality, 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.8.0",
3
+ "version": "0.10.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",