@livepeer-frameworks/player-core 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +78 -0
  2. package/dist/cjs/index.js +792 -146
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/esm/index.js +792 -146
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/player.css +3 -331
  7. package/dist/types/core/GatewayClient.d.ts +3 -4
  8. package/dist/types/core/InteractionController.d.ts +12 -0
  9. package/dist/types/core/MetaTrackManager.d.ts +1 -1
  10. package/dist/types/core/PlayerController.d.ts +18 -2
  11. package/dist/types/core/PlayerInterface.d.ts +10 -0
  12. package/dist/types/core/SeekingUtils.d.ts +3 -1
  13. package/dist/types/core/StreamStateClient.d.ts +1 -1
  14. package/dist/types/players/HlsJsPlayer.d.ts +8 -0
  15. package/dist/types/players/MewsWsPlayer/index.d.ts +1 -1
  16. package/dist/types/players/VideoJsPlayer.d.ts +12 -4
  17. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +1 -1
  18. package/dist/types/players/WebCodecsPlayer/index.d.ts +11 -0
  19. package/dist/types/players/WebCodecsPlayer/types.d.ts +25 -3
  20. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +20 -2
  21. package/dist/types/types.d.ts +32 -1
  22. package/dist/types/vanilla/FrameWorksPlayer.d.ts +5 -5
  23. package/dist/types/vanilla/index.d.ts +3 -3
  24. package/dist/workers/decoder.worker.js +183 -6
  25. package/dist/workers/decoder.worker.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/core/ABRController.ts +1 -1
  28. package/src/core/CodecUtils.ts +1 -1
  29. package/src/core/GatewayClient.ts +8 -10
  30. package/src/core/LiveDurationProxy.ts +0 -1
  31. package/src/core/MetaTrackManager.ts +1 -1
  32. package/src/core/PlayerController.ts +232 -26
  33. package/src/core/PlayerInterface.ts +6 -0
  34. package/src/core/PlayerManager.ts +49 -0
  35. package/src/core/StreamStateClient.ts +3 -3
  36. package/src/core/SubtitleManager.ts +1 -1
  37. package/src/core/TelemetryReporter.ts +1 -1
  38. package/src/core/TimerManager.ts +1 -1
  39. package/src/core/scorer.ts +8 -4
  40. package/src/players/DashJsPlayer.ts +23 -11
  41. package/src/players/HlsJsPlayer.ts +29 -5
  42. package/src/players/MewsWsPlayer/SourceBufferManager.ts +3 -3
  43. package/src/players/MewsWsPlayer/WebSocketManager.ts +0 -1
  44. package/src/players/MewsWsPlayer/index.ts +7 -5
  45. package/src/players/MistPlayer.ts +1 -1
  46. package/src/players/MistWebRTCPlayer/index.ts +1 -1
  47. package/src/players/NativePlayer.ts +2 -2
  48. package/src/players/VideoJsPlayer.ts +33 -31
  49. package/src/players/WebCodecsPlayer/SyncController.ts +1 -2
  50. package/src/players/WebCodecsPlayer/WebSocketController.ts +1 -1
  51. package/src/players/WebCodecsPlayer/index.ts +25 -7
  52. package/src/players/WebCodecsPlayer/types.ts +31 -3
  53. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +20 -13
  54. package/src/players/WebCodecsPlayer/worker/types.ts +4 -0
  55. package/src/styles/player.css +0 -314
  56. package/src/types.ts +43 -1
  57. package/src/vanilla/FrameWorksPlayer.ts +5 -5
  58. package/src/vanilla/index.ts +3 -3
@@ -9,12 +9,12 @@
9
9
  */
10
10
 
11
11
  import { TypedEventEmitter } from './EventEmitter';
12
- import { GatewayClient, GatewayStatus } from './GatewayClient';
12
+ import { GatewayClient } from './GatewayClient';
13
13
  import { StreamStateClient } from './StreamStateClient';
14
14
  import { PlayerManager } from './PlayerManager';
15
15
  import { globalPlayerManager, ensurePlayersRegistered } from './PlayerRegistry';
16
16
  import { ABRController } from './ABRController';
17
- import { InteractionController, type InteractionControllerConfig } from './InteractionController';
17
+ import { InteractionController } from './InteractionController';
18
18
  import { MistReporter } from './MistReporter';
19
19
  import { QualityMonitor } from './QualityMonitor';
20
20
  import { MetaTrackManager } from './MetaTrackManager';
@@ -25,7 +25,6 @@ import {
25
25
  canSeekStream,
26
26
  isMediaStreamSource,
27
27
  supportsPlaybackRate,
28
- isLiveContent,
29
28
  getLatencyTier,
30
29
  type LatencyTier,
31
30
  type LiveThresholds,
@@ -35,6 +34,7 @@ import type {
35
34
  ContentEndpoints,
36
35
  ContentMetadata,
37
36
  EndpointInfo,
37
+ MistStreamInfo,
38
38
  OutputEndpoint,
39
39
  OutputCapabilities,
40
40
  PlayerState,
@@ -51,7 +51,7 @@ export interface PlayerControllerConfig {
51
51
  /** Content identifier (stream name) */
52
52
  contentId: string;
53
53
  /** Content type */
54
- contentType: ContentType;
54
+ contentType?: ContentType;
55
55
 
56
56
  /** Pre-resolved endpoints (skip gateway) */
57
57
  endpoints?: ContentEndpoints;
@@ -182,6 +182,10 @@ export interface PlayerControllerEvents {
182
182
  };
183
183
  }
184
184
 
185
+ // ============================================================================
186
+ // Content Type Resolution Helpers
187
+ // ============================================================================
188
+
185
189
  // ============================================================================
186
190
  // MistServer Source Type Mapping
187
191
  // ============================================================================
@@ -206,6 +210,8 @@ export const MIST_SOURCE_TYPES: Record<string, { hrn: string; player: string; su
206
210
  // ===== WEBSOCKET STREAMING =====
207
211
  'ws/video/mp4': { hrn: 'MP4 WebSocket', player: 'mews', supported: true },
208
212
  'wss/video/mp4': { hrn: 'MP4 WebSocket (SSL)', player: 'mews', supported: true },
213
+ 'ws/video/webm': { hrn: 'WebM WebSocket', player: 'mews', supported: true },
214
+ 'wss/video/webm': { hrn: 'WebM WebSocket (SSL)', player: 'mews', supported: true },
209
215
  'ws/video/raw': { hrn: 'Raw WebSocket', player: 'webcodecs', supported: true },
210
216
  'wss/video/raw': { hrn: 'Raw WebSocket (SSL)', player: 'webcodecs', supported: true },
211
217
  'ws/video/h264': { hrn: 'Annex B WebSocket', player: 'webcodecs', supported: true },
@@ -214,6 +220,7 @@ export const MIST_SOURCE_TYPES: Record<string, { hrn: string; player: string; su
214
220
  // ===== WEBRTC =====
215
221
  'whep': { hrn: 'WebRTC (WHEP)', player: 'native', supported: true },
216
222
  'webrtc': { hrn: 'WebRTC (WebSocket)', player: 'mist-webrtc', supported: true },
223
+ 'mist/webrtc': { hrn: 'MistServer WebRTC', player: 'mist-webrtc', supported: true },
217
224
 
218
225
  // ===== AUDIO ONLY =====
219
226
  'html5/audio/aac': { hrn: 'AAC progressive', player: 'native', supported: true },
@@ -259,13 +266,18 @@ export const PROTOCOL_TO_MIME: Record<string, string> = {
259
266
  'WEBM': 'html5/video/webm',
260
267
  'WHEP': 'whep',
261
268
  'WebRTC': 'webrtc',
269
+ 'MIST_WEBRTC': 'mist/webrtc', // MistServer native WebRTC signaling
262
270
 
263
271
  // WebSocket variants
264
272
  'MEWS': 'ws/video/mp4',
265
273
  'MEWS_WS': 'ws/video/mp4',
266
274
  'MEWS_WSS': 'wss/video/mp4',
275
+ 'MEWS_WEBM': 'ws/video/webm',
276
+ 'MEWS_WEBM_SSL': 'wss/video/webm',
267
277
  'RAW_WS': 'ws/video/raw',
268
278
  'RAW_WSS': 'wss/video/raw',
279
+ 'H264_WS': 'ws/video/h264',
280
+ 'H264_WSS': 'wss/video/h264',
269
281
 
270
282
  // Audio
271
283
  'AAC': 'html5/audio/aac',
@@ -355,7 +367,7 @@ export function buildStreamInfoFromEndpoints(
355
367
  if (typeof primary.outputs === 'string') {
356
368
  try {
357
369
  outputs = JSON.parse(primary.outputs);
358
- } catch (e) {
370
+ } catch {
359
371
  console.warn('[buildStreamInfoFromEndpoints] Failed to parse outputs JSON');
360
372
  outputs = {};
361
373
  }
@@ -367,7 +379,6 @@ export function buildStreamInfoFromEndpoints(
367
379
  const sources: StreamSource[] = [];
368
380
  const oKeys = Object.keys(outputs);
369
381
 
370
- // Helper to attach MistServer sources
371
382
  const attachMistSource = (html?: string, playerJs?: string) => {
372
383
  if (!html && !playerJs) return;
373
384
  const src: StreamSource = {
@@ -473,7 +484,7 @@ export function buildStreamInfoFromEndpoints(
473
484
  * @example
474
485
  * ```typescript
475
486
  * const controller = new PlayerController({
476
- * contentId: 'my-stream',
487
+ * contentId: 'pk_...', // playbackId (view key)
477
488
  * contentType: 'live',
478
489
  * gatewayUrl: 'https://gateway.example.com/graphql',
479
490
  * });
@@ -493,7 +504,6 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
493
504
  private state: PlayerState = 'booting';
494
505
  private lastEmittedState: PlayerState | null = null;
495
506
  private suppressPlayPauseEventsUntil = 0;
496
- private suppressPlayPauseEventsUntil = 0;
497
507
 
498
508
  private gatewayClient: GatewayClient | null = null;
499
509
  private streamStateClient: StreamStateClient | null = null;
@@ -508,6 +518,10 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
508
518
  private streamState: StreamState | null = null;
509
519
  /** Tracks parsed from MistServer JSON response (used for direct MistServer mode) */
510
520
  private mistTracks: StreamTrack[] | null = null;
521
+ /** Gateway-seeded metadata (used as base for Mist enrichment) */
522
+ private metadataSeed: ContentMetadata | null = null;
523
+ /** Merged metadata (gateway seed + Mist enrichment) */
524
+ private metadata: ContentMetadata | null = null;
511
525
 
512
526
  private cleanupFns: Array<() => void> = [];
513
527
  private isDestroyed: boolean = false;
@@ -698,6 +712,8 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
698
712
  this.endpoints = null;
699
713
  this.streamInfo = null;
700
714
  this.streamState = null;
715
+ this.metadataSeed = null;
716
+ this.metadata = null;
701
717
  this.videoElement = null;
702
718
  this.currentPlayer = null;
703
719
  this.lastEmittedState = null;
@@ -738,7 +754,130 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
738
754
 
739
755
  /** Get content metadata (title, description, duration, etc.) */
740
756
  getMetadata(): ContentMetadata | null {
741
- return this.endpoints?.metadata ?? null;
757
+ return this.metadata ?? null;
758
+ }
759
+
760
+ // ============================================================================
761
+ // Metadata Merge (Gateway seed + Mist enrichment)
762
+ // ============================================================================
763
+
764
+ private setMetadataSeed(seed: ContentMetadata | null | undefined): void {
765
+ this.metadataSeed = seed ? { ...seed } : null;
766
+ this.refreshMergedMetadata();
767
+ }
768
+
769
+ private refreshMergedMetadata(): void {
770
+ const seed = this.metadataSeed ? { ...this.metadataSeed } : null;
771
+ const mist = this.streamState?.streamInfo;
772
+ const streamStatus = this.streamState?.status;
773
+
774
+ if (!seed && !mist) {
775
+ this.metadata = null;
776
+ return;
777
+ }
778
+
779
+ const merged: ContentMetadata = seed ? { ...seed } : {};
780
+
781
+ if (mist) {
782
+ merged.mist = this.sanitizeMistInfo(mist);
783
+ if (mist.type) {
784
+ merged.contentType = mist.type;
785
+ merged.isLive = mist.type === 'live';
786
+ }
787
+ if (streamStatus) {
788
+ merged.status = streamStatus;
789
+ }
790
+ if (mist.meta?.duration && (!merged.durationSeconds || merged.durationSeconds <= 0)) {
791
+ merged.durationSeconds = Math.round(mist.meta.duration);
792
+ }
793
+ if (mist.meta?.tracks) {
794
+ merged.tracks = this.buildMetadataTracks(mist.meta.tracks);
795
+ }
796
+ }
797
+
798
+ this.metadata = merged;
799
+ }
800
+
801
+ private buildMetadataTracks(tracksObj: Record<string, unknown>): ContentMetadata['tracks'] {
802
+ const tracks: NonNullable<ContentMetadata['tracks']> = [];
803
+ for (const [, trackData] of Object.entries(tracksObj)) {
804
+ const t = trackData as Record<string, unknown>;
805
+ const trackType = t.type as string;
806
+ if (trackType !== 'video' && trackType !== 'audio' && trackType !== 'meta') {
807
+ continue;
808
+ }
809
+
810
+ const bitrate = typeof t.bps === 'number' ? Math.round(t.bps) : undefined;
811
+ const fps = typeof t.fpks === 'number' ? t.fpks / 1000 : undefined;
812
+
813
+ tracks.push({
814
+ type: trackType,
815
+ codec: t.codec as string | undefined,
816
+ width: t.width as number | undefined,
817
+ height: t.height as number | undefined,
818
+ bitrate,
819
+ fps,
820
+ channels: t.channels as number | undefined,
821
+ sampleRate: t.rate as number | undefined,
822
+ });
823
+ }
824
+ return tracks.length ? tracks : undefined;
825
+ }
826
+
827
+ private sanitizeMistInfo(info: MistStreamInfo): MistStreamInfo {
828
+ const sanitized: MistStreamInfo = {
829
+ error: info.error,
830
+ on_error: info.on_error,
831
+ perc: info.perc,
832
+ type: info.type,
833
+ hasVideo: info.hasVideo,
834
+ hasAudio: info.hasAudio,
835
+ unixoffset: info.unixoffset,
836
+ lastms: info.lastms,
837
+ };
838
+
839
+ if (info.source) {
840
+ sanitized.source = info.source.map((src) => ({
841
+ url: src.url,
842
+ type: src.type,
843
+ priority: src.priority,
844
+ simul_tracks: src.simul_tracks,
845
+ relurl: src.relurl,
846
+ }));
847
+ }
848
+
849
+ if (info.meta) {
850
+ sanitized.meta = {
851
+ buffer_window: info.meta.buffer_window,
852
+ duration: info.meta.duration,
853
+ mistUrl: info.meta.mistUrl,
854
+ };
855
+
856
+ if (info.meta.tracks) {
857
+ const tracks: Record<string, any> = {};
858
+ for (const [key, track] of Object.entries(info.meta.tracks)) {
859
+ tracks[key] = {
860
+ type: track.type,
861
+ codec: track.codec,
862
+ width: track.width,
863
+ height: track.height,
864
+ bps: track.bps,
865
+ fpks: track.fpks,
866
+ codecstring: track.codecstring,
867
+ firstms: track.firstms,
868
+ lastms: track.lastms,
869
+ lang: track.lang,
870
+ idx: track.idx,
871
+ channels: track.channels,
872
+ rate: track.rate,
873
+ size: track.size,
874
+ };
875
+ }
876
+ sanitized.meta.tracks = tracks;
877
+ }
878
+ }
879
+
880
+ return sanitized;
742
881
  }
743
882
 
744
883
  /** Get stream info (sources + tracks for player selection) */
@@ -905,6 +1044,29 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
905
1044
  return this._supportsPlaybackRate;
906
1045
  }
907
1046
 
1047
+ /** Resolve content type from config override or Gateway metadata */
1048
+ private getResolvedContentType(): ContentType | null {
1049
+ if (this.config.contentType) {
1050
+ return this.config.contentType;
1051
+ }
1052
+ const metadata = this.getMetadata();
1053
+ const metaType = metadata?.contentType?.toLowerCase();
1054
+ if (metaType === 'live' || metaType === 'clip' || metaType === 'dvr' || metaType === 'vod') {
1055
+ return metaType as ContentType;
1056
+ }
1057
+ const mistType = this.streamState?.streamInfo?.type;
1058
+ if (mistType === 'live' || mistType === 'vod') {
1059
+ return mistType;
1060
+ }
1061
+ if (metadata?.isLive === true) {
1062
+ return 'live';
1063
+ }
1064
+ if (metadata?.isLive === false) {
1065
+ return 'vod';
1066
+ }
1067
+ return null;
1068
+ }
1069
+
908
1070
  /** Check if source is WebRTC/MediaStream */
909
1071
  isWebRTCSource(): boolean {
910
1072
  return this._isWebRTC;
@@ -918,14 +1080,33 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
918
1080
 
919
1081
  /** Check if content is effectively live (live or DVR still recording) */
920
1082
  isEffectivelyLive(): boolean {
921
- const { contentType } = this.config;
1083
+ const contentType = this.getResolvedContentType() ?? 'live';
922
1084
  const metadata = this.getMetadata();
923
- return contentType === 'live' || (contentType === 'dvr' && metadata?.dvrStatus === 'recording');
1085
+
1086
+ // Explicit VOD content types are never live
1087
+ if (contentType === 'vod' || contentType === 'clip') {
1088
+ return false;
1089
+ }
1090
+
1091
+ // If Gateway metadata says it's not live, trust it
1092
+ if (metadata?.isLive === false) {
1093
+ return false;
1094
+ }
1095
+
1096
+ // DVR that's finished recording is not live
1097
+ if (contentType === 'dvr' && metadata?.dvrStatus === 'completed') {
1098
+ return false;
1099
+ }
1100
+
1101
+ // Default: trust contentType or duration-based detection
1102
+ return contentType === 'live' ||
1103
+ (contentType === 'dvr' && metadata?.dvrStatus === 'recording') ||
1104
+ !Number.isFinite(this.getDuration());
924
1105
  }
925
1106
 
926
1107
  /** Check if content is strictly live (not DVR/clip/vod) */
927
1108
  isLive(): boolean {
928
- return this.config.contentType === 'live';
1109
+ return (this.getResolvedContentType() ?? 'live') === 'live';
929
1110
  }
930
1111
 
931
1112
  /**
@@ -935,7 +1116,9 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
935
1116
  * DVR-while-recording needs cold start because MistServer may not be serving the VOD yet
936
1117
  */
937
1118
  needsColdStart(): boolean {
938
- return this.config.contentType !== 'live';
1119
+ const contentType = this.getResolvedContentType();
1120
+ if (!contentType) return true;
1121
+ return contentType !== 'live';
939
1122
  }
940
1123
 
941
1124
  /**
@@ -1509,7 +1692,6 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
1509
1692
 
1510
1693
  // Check if values changed
1511
1694
  const seekableChanged = this._seekableStart !== seekableStart || this._liveEdge !== liveEdge;
1512
- const canSeekChanged = this._canSeek !== this._canSeek; // Already updated above
1513
1695
 
1514
1696
  this._seekableStart = seekableStart;
1515
1697
  this._liveEdge = liveEdge;
@@ -1933,11 +2115,12 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
1933
2115
  // ============================================================================
1934
2116
 
1935
2117
  private async resolveEndpoints(): Promise<void> {
1936
- const { endpoints, gatewayUrl, mistUrl, contentType, contentId, authToken } = this.config;
2118
+ const { endpoints, gatewayUrl, mistUrl, contentId, authToken } = this.config;
1937
2119
 
1938
2120
  // Priority 1: Use pre-resolved endpoints if provided
1939
2121
  if (endpoints?.primary) {
1940
2122
  this.endpoints = endpoints;
2123
+ this.setMetadataSeed(endpoints.metadata ?? null);
1941
2124
  this.setState('gateway_ready', { gatewayStatus: 'ready' });
1942
2125
  return;
1943
2126
  }
@@ -1950,7 +2133,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
1950
2133
 
1951
2134
  // Priority 3: Gateway resolution
1952
2135
  if (gatewayUrl) {
1953
- await this.resolveFromGateway(gatewayUrl, contentType, contentId, authToken);
2136
+ await this.resolveFromGateway(gatewayUrl, contentId, authToken);
1954
2137
  return;
1955
2138
  }
1956
2139
 
@@ -1973,7 +2156,13 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
1973
2156
  throw new Error(`MistServer HTTP ${response.status}`);
1974
2157
  }
1975
2158
 
1976
- const data = await response.json();
2159
+ // MistServer can return JSONP: callback({...}); - strip wrapper if present
2160
+ let text = await response.text();
2161
+ const jsonpMatch = text.match(/^[^(]+\(([\s\S]*)\);?$/);
2162
+ if (jsonpMatch) {
2163
+ text = jsonpMatch[1];
2164
+ }
2165
+ const data = JSON.parse(text);
1977
2166
 
1978
2167
  if (data.error) {
1979
2168
  throw new Error(data.error);
@@ -2008,6 +2197,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2008
2197
  };
2009
2198
 
2010
2199
  this.endpoints = { primary, fallbacks: [] };
2200
+ this.setMetadataSeed(null);
2011
2201
 
2012
2202
  // Parse track metadata from MistServer response
2013
2203
  if (data.meta?.tracks && typeof data.meta.tracks === 'object') {
@@ -2031,11 +2221,17 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2031
2221
  */
2032
2222
  private mapMistTypeToProtocol(mistType: string): string {
2033
2223
  // WebCodecs raw streams - check BEFORE generic ws/ catch-all
2034
- // MistServer rawws.js uses 'ws/video/raw', mews.js uses 'ws/video/mp4' and 'ws/video/webm'
2035
2224
  if (mistType === 'ws/video/raw') return 'RAW_WS';
2036
2225
  if (mistType === 'wss/video/raw') return 'RAW_WSS';
2037
- // MEWS (MP4/WebM over WebSocket) - catches remaining ws/* types
2038
- if (mistType.startsWith('ws/') || mistType.startsWith('wss/')) return 'MEWS_WS';
2226
+ // Annex B H264 over WebSocket (video-only, uses same 12-byte header as raw)
2227
+ if (mistType === 'ws/video/h264') return 'H264_WS';
2228
+ if (mistType === 'wss/video/h264') return 'H264_WSS';
2229
+ // WebM over WebSocket - check BEFORE generic ws/ catch-all
2230
+ if (mistType === 'ws/video/webm') return 'MEWS_WEBM';
2231
+ if (mistType === 'wss/video/webm') return 'MEWS_WEBM_SSL';
2232
+ // MEWS MP4 over WebSocket - catch remaining ws/* types (defaults to mp4)
2233
+ if (mistType.startsWith('ws/')) return 'MEWS_WS';
2234
+ if (mistType.startsWith('wss/')) return 'MEWS_WSS';
2039
2235
  if (mistType.includes('webrtc')) return 'MIST_WEBRTC';
2040
2236
  if (mistType.includes('mpegurl') || mistType.includes('m3u8')) return 'HLS';
2041
2237
  if (mistType.includes('dash') || mistType.includes('mpd')) return 'DASH';
@@ -2064,7 +2260,6 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2064
2260
  */
2065
2261
  private async resolveFromGateway(
2066
2262
  gatewayUrl: string,
2067
- contentType: ContentType,
2068
2263
  contentId: string,
2069
2264
  authToken?: string
2070
2265
  ): Promise<void> {
@@ -2072,7 +2267,6 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2072
2267
 
2073
2268
  this.gatewayClient = new GatewayClient({
2074
2269
  gatewayUrl,
2075
- contentType,
2076
2270
  contentId,
2077
2271
  authToken,
2078
2272
  });
@@ -2088,6 +2282,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2088
2282
 
2089
2283
  try {
2090
2284
  this.endpoints = await this.gatewayClient.resolve();
2285
+ this.setMetadataSeed(this.endpoints?.metadata ?? null);
2091
2286
  this.setState('gateway_ready', { gatewayStatus: 'ready' });
2092
2287
  } catch (error) {
2093
2288
  const message = error instanceof Error ? error.message : 'Gateway resolution failed';
@@ -2097,10 +2292,17 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2097
2292
  }
2098
2293
 
2099
2294
  private startStreamStatePolling(): void {
2100
- const { contentType, contentId, mistUrl } = this.config;
2295
+ const { contentId, mistUrl } = this.config;
2296
+ const contentType = this.getResolvedContentType();
2101
2297
 
2102
2298
  // Only poll for live-like content. DVR should only poll while recording.
2103
- if (contentType !== 'live' && contentType !== 'dvr') return;
2299
+ // If contentType is unknown but mistUrl is provided, still poll so we can
2300
+ // detect when a stream comes online and initialize playback.
2301
+ if (contentType == null) {
2302
+ if (!mistUrl) return;
2303
+ } else if (contentType !== 'live' && contentType !== 'dvr') {
2304
+ return;
2305
+ }
2104
2306
  if (contentType === 'dvr') {
2105
2307
  const dvrStatus = this.getMetadata()?.dvrStatus;
2106
2308
  if (dvrStatus && dvrStatus !== 'recording') return;
@@ -2145,6 +2347,9 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2145
2347
  }
2146
2348
  }
2147
2349
 
2350
+ // Merge Mist metadata into the unified metadata surface
2351
+ this.refreshMergedMetadata();
2352
+
2148
2353
  this.emit('streamStateChange', { state });
2149
2354
 
2150
2355
  // Auto-play when stream transitions from offline to online
@@ -2213,6 +2418,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2213
2418
  };
2214
2419
 
2215
2420
  this.endpoints = { primary, fallbacks: [] };
2421
+ this.setMetadataSeed(this.endpoints.metadata ?? null);
2216
2422
 
2217
2423
  // Parse track metadata from stream info
2218
2424
  if (streamInfo.meta?.tracks && typeof streamInfo.meta.tracks === 'object') {
@@ -2373,7 +2579,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2373
2579
  this.initializeSubControllers();
2374
2580
  this.emit('ready', { videoElement: el });
2375
2581
  },
2376
- onTimeUpdate: (t) => {
2582
+ onTimeUpdate: (_t) => {
2377
2583
  if (this.isDestroyed) return;
2378
2584
  // Defensive: keep video element attached even if some other lifecycle cleared the container.
2379
2585
  // (Playback can continue even when detached, which looks like "audio only".)
@@ -2663,7 +2869,7 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2663
2869
  if (playerName === 'webcodecs') {
2664
2870
  this.suppressPlayPauseEvents(250);
2665
2871
  }
2666
- if (hasFrameStep && player) {
2872
+ if (hasFrameStep && player && player.frameStep) {
2667
2873
  player.frameStep(direction, seconds);
2668
2874
  return true;
2669
2875
  }
@@ -61,6 +61,12 @@ export interface PlayerOptions {
61
61
  onPlaying?: () => void;
62
62
  onCanPlay?: () => void;
63
63
  onDurationChange?: (duration: number) => void;
64
+ /** HLS.js configuration override (merged with defaults) */
65
+ hlsConfig?: Record<string, unknown>;
66
+ /** DASH.js configuration override (merged with defaults) */
67
+ dashConfig?: Record<string, unknown>;
68
+ /** Video.js VHS configuration override (merged with defaults) */
69
+ vhsConfig?: Record<string, unknown>;
64
70
  }
65
71
 
66
72
  export interface PlayerCapability {
@@ -349,6 +349,14 @@ export class PlayerManager {
349
349
  selectionSources.forEach((s, idx) => selectionIndexBySource.set(s, idx));
350
350
  const totalSources = selectionSources.length;
351
351
 
352
+ const requiredTracks: Array<'video' | 'audio'> = [];
353
+ if (streamInfo.meta.tracks.some((t) => t.type === 'video')) {
354
+ requiredTracks.push('video');
355
+ }
356
+ if (streamInfo.meta.tracks.some((t) => t.type === 'audio')) {
357
+ requiredTracks.push('audio');
358
+ }
359
+
352
360
  // Track seen player+sourceType pairs to avoid duplicates
353
361
  const seenPairs = new Set<string>();
354
362
 
@@ -439,6 +447,47 @@ export class PlayerManager {
439
447
  continue;
440
448
  }
441
449
 
450
+ if (Array.isArray(tracktypes) && requiredTracks.length > 0) {
451
+ const missing = requiredTracks.filter((t) => !tracktypes.includes(t));
452
+ if (missing.length > 0) {
453
+ const priorityScore =
454
+ 1 - player.capability.priority / Math.max(maxPriority, 1);
455
+ const sourceScore =
456
+ 1 - sourceListIndex / Math.max(totalSources - 1, 1);
457
+ const playerScore = scorePlayer(
458
+ tracktypes,
459
+ player.capability.priority,
460
+ sourceListIndex,
461
+ {
462
+ maxPriority,
463
+ totalSources,
464
+ playerShortname: player.capability.shortname,
465
+ mimeType: source.type,
466
+ playbackMode: effectiveMode,
467
+ }
468
+ );
469
+
470
+ combinations.push({
471
+ player: player.capability.shortname,
472
+ playerName: player.capability.name,
473
+ source,
474
+ sourceIndex,
475
+ sourceType: source.type,
476
+ score: playerScore.total,
477
+ compatible: false,
478
+ incompatibleReason: `Missing required tracks: ${missing.join(', ')}`,
479
+ scoreBreakdown: {
480
+ trackScore: 0,
481
+ trackTypes: tracktypes,
482
+ priorityScore,
483
+ sourceScore,
484
+ weights: { tracks: 0.5, priority: 0.1, source: 0.05 },
485
+ },
486
+ });
487
+ continue;
488
+ }
489
+ }
490
+
442
491
  // Compatible - calculate full score
443
492
  const trackScore = Array.isArray(tracktypes)
444
493
  ? tracktypes.reduce(
@@ -114,7 +114,7 @@ function getStatusMessage(status: StreamStatus, percentage?: number): string {
114
114
  * ```typescript
115
115
  * const client = new StreamStateClient({
116
116
  * mistBaseUrl: 'https://mist.example.com',
117
- * streamName: 'my-stream',
117
+ * streamName: 'pk_...', // playbackId (view key)
118
118
  * });
119
119
  *
120
120
  * client.on('stateChange', ({ state }) => console.log('State:', state));
@@ -291,7 +291,7 @@ export class StreamStateClient extends TypedEventEmitter<StreamStateClientEvents
291
291
  .replace(/^https:/, 'wss:')
292
292
  .replace(/\/$/, '');
293
293
 
294
- const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js`);
294
+ const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`);
295
295
  this.ws = ws;
296
296
 
297
297
  ws.onopen = () => {
@@ -337,7 +337,7 @@ export class StreamStateClient extends TypedEventEmitter<StreamStateClientEvents
337
337
  const { mistBaseUrl, streamName, pollInterval } = this.config;
338
338
 
339
339
  try {
340
- const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js`;
340
+ const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`;
341
341
  const response = await fetch(url, {
342
342
  method: 'GET',
343
343
  headers: { 'Accept': 'application/json' },
@@ -246,7 +246,7 @@ export class SubtitleManager {
246
246
  for (const cue of newCues) {
247
247
  try {
248
248
  textTrack.addCue(cue);
249
- } catch (e) {
249
+ } catch {
250
250
  // Ignore errors from invalid cue timing
251
251
  }
252
252
  }
@@ -1,4 +1,4 @@
1
- import type { TelemetryPayload, TelemetryOptions, PlaybackQuality, ContentType } from '../types';
1
+ import type { TelemetryPayload, PlaybackQuality, ContentType } from '../types';
2
2
 
3
3
  /**
4
4
  * Generate a unique session ID
@@ -140,7 +140,7 @@ export class TimerManager {
140
140
  stopAll(): void {
141
141
  const count = this.timers.size;
142
142
 
143
- for (const [internalId, entry] of this.timers) {
143
+ for (const [_internalId, entry] of this.timers) {
144
144
  if (entry.isInterval) {
145
145
  clearInterval(entry.id);
146
146
  } else {
@@ -182,6 +182,9 @@ export const PROTOCOL_PENALTIES: Record<string, number> = {
182
182
  // MEWS - heavy penalty, prefer HLS/WebRTC (reference mews.js has issues)
183
183
  'ws/video/mp4': 0.50,
184
184
  'wss/video/mp4': 0.50,
185
+ // Native Mist WebRTC signaling - treat like MEWS (legacy/less stable than WHEP)
186
+ 'webrtc': 0.50,
187
+ 'mist/webrtc': 0.50,
185
188
  // DASH - heavy penalty, broken implementation
186
189
  'dash/video/mp4': 0.90, // Below legacy
187
190
  'dash/video/webm': 0.95,
@@ -258,8 +261,8 @@ export const MODE_PROTOCOL_BONUSES: Record<PlaybackMode, Record<string, number>>
258
261
  'wss/video/h264': 0.52,
259
262
  // WHEP/WebRTC: sub-second latency
260
263
  'whep': 0.50,
261
- 'webrtc': 0.45,
262
- 'mist/webrtc': 0.45,
264
+ 'webrtc': 0.25,
265
+ 'mist/webrtc': 0.25,
263
266
  // MP4/WS (MEWS): 2-5s latency, good fallback
264
267
  'ws/video/mp4': 0.30,
265
268
  'wss/video/mp4': 0.30,
@@ -283,6 +286,7 @@ export const MODE_PROTOCOL_BONUSES: Record<PlaybackMode, Record<string, number>>
283
286
  // WebRTC: minimal for quality mode
284
287
  'whep': 0.05,
285
288
  'webrtc': 0.05,
289
+ 'mist/webrtc': 0.05,
286
290
  },
287
291
  'vod': {
288
292
  // VOD/Clip: Prefer seekable protocols, EXCLUDE WebRTC (no seek support)
@@ -306,8 +310,8 @@ export const MODE_PROTOCOL_BONUSES: Record<PlaybackMode, Record<string, number>>
306
310
  'html5/video/mp4': 0.42,
307
311
  // WHEP/WebRTC: good for low latency
308
312
  'whep': 0.38,
309
- 'webrtc': 0.35,
310
- 'mist/webrtc': 0.35,
313
+ 'webrtc': 0.20,
314
+ 'mist/webrtc': 0.20,
311
315
  // MP4/WS (MEWS): lower latency than HLS
312
316
  'ws/video/mp4': 0.30,
313
317
  'wss/video/mp4': 0.30,