@livepeer-frameworks/player-core 0.0.3
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/cjs/index.js +19493 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +19398 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/player.css +2140 -0
- package/dist/types/core/ABRController.d.ts +164 -0
- package/dist/types/core/CodecUtils.d.ts +54 -0
- package/dist/types/core/Disposable.d.ts +61 -0
- package/dist/types/core/EventEmitter.d.ts +73 -0
- package/dist/types/core/GatewayClient.d.ts +144 -0
- package/dist/types/core/InteractionController.d.ts +121 -0
- package/dist/types/core/LiveDurationProxy.d.ts +102 -0
- package/dist/types/core/MetaTrackManager.d.ts +220 -0
- package/dist/types/core/MistReporter.d.ts +163 -0
- package/dist/types/core/MistSignaling.d.ts +148 -0
- package/dist/types/core/PlayerController.d.ts +665 -0
- package/dist/types/core/PlayerInterface.d.ts +230 -0
- package/dist/types/core/PlayerManager.d.ts +182 -0
- package/dist/types/core/PlayerRegistry.d.ts +27 -0
- package/dist/types/core/QualityMonitor.d.ts +184 -0
- package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
- package/dist/types/core/SeekingUtils.d.ts +142 -0
- package/dist/types/core/StreamStateClient.d.ts +108 -0
- package/dist/types/core/SubtitleManager.d.ts +111 -0
- package/dist/types/core/TelemetryReporter.d.ts +79 -0
- package/dist/types/core/TimeFormat.d.ts +97 -0
- package/dist/types/core/TimerManager.d.ts +83 -0
- package/dist/types/core/UrlUtils.d.ts +81 -0
- package/dist/types/core/detector.d.ts +149 -0
- package/dist/types/core/index.d.ts +49 -0
- package/dist/types/core/scorer.d.ts +167 -0
- package/dist/types/core/selector.d.ts +9 -0
- package/dist/types/index.d.ts +45 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/players/DashJsPlayer.d.ts +102 -0
- package/dist/types/players/HlsJsPlayer.d.ts +70 -0
- package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
- package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
- package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
- package/dist/types/players/MistPlayer.d.ts +25 -0
- package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
- package/dist/types/players/NativePlayer.d.ts +143 -0
- package/dist/types/players/VideoJsPlayer.d.ts +59 -0
- package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
- package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
- package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
- package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
- package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
- package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
- package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
- package/dist/types/players/index.d.ts +14 -0
- package/dist/types/styles/index.d.ts +11 -0
- package/dist/types/types.d.ts +363 -0
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
- package/dist/types/vanilla/index.d.ts +19 -0
- package/dist/workers/decoder.worker.js +989 -0
- package/dist/workers/decoder.worker.js.map +1 -0
- package/package.json +80 -0
- package/src/core/ABRController.ts +550 -0
- package/src/core/CodecUtils.ts +257 -0
- package/src/core/Disposable.ts +120 -0
- package/src/core/EventEmitter.ts +113 -0
- package/src/core/GatewayClient.ts +439 -0
- package/src/core/InteractionController.ts +712 -0
- package/src/core/LiveDurationProxy.ts +270 -0
- package/src/core/MetaTrackManager.ts +753 -0
- package/src/core/MistReporter.ts +543 -0
- package/src/core/MistSignaling.ts +346 -0
- package/src/core/PlayerController.ts +2829 -0
- package/src/core/PlayerInterface.ts +432 -0
- package/src/core/PlayerManager.ts +900 -0
- package/src/core/PlayerRegistry.ts +149 -0
- package/src/core/QualityMonitor.ts +597 -0
- package/src/core/ScreenWakeLockManager.ts +163 -0
- package/src/core/SeekingUtils.ts +364 -0
- package/src/core/StreamStateClient.ts +457 -0
- package/src/core/SubtitleManager.ts +297 -0
- package/src/core/TelemetryReporter.ts +308 -0
- package/src/core/TimeFormat.ts +205 -0
- package/src/core/TimerManager.ts +209 -0
- package/src/core/UrlUtils.ts +179 -0
- package/src/core/detector.ts +382 -0
- package/src/core/index.ts +140 -0
- package/src/core/scorer.ts +553 -0
- package/src/core/selector.ts +16 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/players/DashJsPlayer.ts +642 -0
- package/src/players/HlsJsPlayer.ts +483 -0
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
- package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
- package/src/players/MewsWsPlayer/index.ts +1065 -0
- package/src/players/MewsWsPlayer/types.ts +106 -0
- package/src/players/MistPlayer.ts +188 -0
- package/src/players/MistWebRTCPlayer/index.ts +703 -0
- package/src/players/NativePlayer.ts +820 -0
- package/src/players/VideoJsPlayer.ts +643 -0
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
- package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
- package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
- package/src/players/WebCodecsPlayer/index.ts +1650 -0
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
- package/src/players/WebCodecsPlayer/types.ts +542 -0
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
- package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
- package/src/players/index.ts +22 -0
- package/src/styles/animations.css +21 -0
- package/src/styles/index.ts +52 -0
- package/src/styles/player.css +2126 -0
- package/src/styles/tailwind.css +1015 -0
- package/src/types.ts +421 -0
- package/src/vanilla/FrameWorksPlayer.ts +367 -0
- package/src/vanilla/index.ts +22 -0
|
@@ -0,0 +1,2829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlayerController.ts
|
|
3
|
+
*
|
|
4
|
+
* Main headless orchestrator for the player. This class encapsulates all business logic
|
|
5
|
+
* (gateway resolution, stream state polling, player selection/initialization) in a
|
|
6
|
+
* framework-agnostic manner.
|
|
7
|
+
*
|
|
8
|
+
* Both React and Vanilla wrappers use this class internally.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TypedEventEmitter } from './EventEmitter';
|
|
12
|
+
import { GatewayClient, GatewayStatus } from './GatewayClient';
|
|
13
|
+
import { StreamStateClient } from './StreamStateClient';
|
|
14
|
+
import { PlayerManager } from './PlayerManager';
|
|
15
|
+
import { globalPlayerManager, ensurePlayersRegistered } from './PlayerRegistry';
|
|
16
|
+
import { ABRController } from './ABRController';
|
|
17
|
+
import { InteractionController, type InteractionControllerConfig } from './InteractionController';
|
|
18
|
+
import { MistReporter } from './MistReporter';
|
|
19
|
+
import { QualityMonitor } from './QualityMonitor';
|
|
20
|
+
import { MetaTrackManager } from './MetaTrackManager';
|
|
21
|
+
import {
|
|
22
|
+
calculateSeekableRange,
|
|
23
|
+
calculateLiveThresholds,
|
|
24
|
+
calculateIsNearLive,
|
|
25
|
+
canSeekStream,
|
|
26
|
+
isMediaStreamSource,
|
|
27
|
+
supportsPlaybackRate,
|
|
28
|
+
isLiveContent,
|
|
29
|
+
getLatencyTier,
|
|
30
|
+
type LatencyTier,
|
|
31
|
+
type LiveThresholds,
|
|
32
|
+
} from './SeekingUtils';
|
|
33
|
+
import type { ABRMode, PlaybackQuality, ContentType } from '../types';
|
|
34
|
+
import type {
|
|
35
|
+
ContentEndpoints,
|
|
36
|
+
ContentMetadata,
|
|
37
|
+
EndpointInfo,
|
|
38
|
+
OutputEndpoint,
|
|
39
|
+
OutputCapabilities,
|
|
40
|
+
PlayerState,
|
|
41
|
+
PlayerStateContext,
|
|
42
|
+
StreamState,
|
|
43
|
+
} from '../types';
|
|
44
|
+
import type { StreamInfo, StreamSource, StreamTrack, IPlayer, PlayerOptions as CorePlayerOptions } from './PlayerInterface';
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Types
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
export interface PlayerControllerConfig {
|
|
51
|
+
/** Content identifier (stream name) */
|
|
52
|
+
contentId: string;
|
|
53
|
+
/** Content type */
|
|
54
|
+
contentType: ContentType;
|
|
55
|
+
|
|
56
|
+
/** Pre-resolved endpoints (skip gateway) */
|
|
57
|
+
endpoints?: ContentEndpoints;
|
|
58
|
+
|
|
59
|
+
/** Gateway URL (for FrameWorks Gateway resolution) */
|
|
60
|
+
gatewayUrl?: string;
|
|
61
|
+
/** Direct MistServer base URL (bypasses Gateway, fetches json_{contentId}.js directly) */
|
|
62
|
+
mistUrl?: string;
|
|
63
|
+
/** Auth token for private streams */
|
|
64
|
+
authToken?: string;
|
|
65
|
+
|
|
66
|
+
/** Playback options */
|
|
67
|
+
autoplay?: boolean;
|
|
68
|
+
muted?: boolean;
|
|
69
|
+
controls?: boolean;
|
|
70
|
+
poster?: string;
|
|
71
|
+
|
|
72
|
+
/** Debug logging */
|
|
73
|
+
debug?: boolean;
|
|
74
|
+
|
|
75
|
+
/** Custom PlayerManager instance (optional, uses global by default) */
|
|
76
|
+
playerManager?: PlayerManager;
|
|
77
|
+
|
|
78
|
+
// Dev mode overrides - passed to PlayerManager during player selection
|
|
79
|
+
/** Force a specific player (e.g., 'hlsjs', 'dashjs', 'native') */
|
|
80
|
+
forcePlayer?: string;
|
|
81
|
+
/** Force a specific MIME type (e.g., 'html5/application/vnd.apple.mpegurl') */
|
|
82
|
+
forceType?: string;
|
|
83
|
+
/** Force a specific source index */
|
|
84
|
+
forceSource?: number;
|
|
85
|
+
/** Playback mode preference */
|
|
86
|
+
playbackMode?: 'auto' | 'low-latency' | 'quality' | 'vod';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PlayerControllerEvents {
|
|
90
|
+
/** Player state changed */
|
|
91
|
+
stateChange: { state: PlayerState; context?: PlayerStateContext };
|
|
92
|
+
/** Stream state changed (for live streams) */
|
|
93
|
+
streamStateChange: { state: StreamState };
|
|
94
|
+
/** Time update during playback */
|
|
95
|
+
timeUpdate: { currentTime: number; duration: number };
|
|
96
|
+
/** Error occurred */
|
|
97
|
+
error: { error: string; code?: string };
|
|
98
|
+
/** Error was cleared (auto-cleared or manually) */
|
|
99
|
+
errorCleared: void;
|
|
100
|
+
/** Player ready with video element */
|
|
101
|
+
ready: { videoElement: HTMLVideoElement };
|
|
102
|
+
/** Controller destroyed */
|
|
103
|
+
destroyed: void;
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Playback Events (Phase A5)
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/** Player/source was selected */
|
|
110
|
+
playerSelected: { player: string; source: StreamSource; score: number };
|
|
111
|
+
/** Quality level changed (ABR switch) */
|
|
112
|
+
qualityChanged: { fromLevel?: string; toLevel: string };
|
|
113
|
+
/** Volume or mute state changed */
|
|
114
|
+
volumeChange: { volume: number; muted: boolean };
|
|
115
|
+
/** Fullscreen state changed */
|
|
116
|
+
fullscreenChange: { isFullscreen: boolean };
|
|
117
|
+
/** Picture-in-Picture state changed */
|
|
118
|
+
pipChange: { isPiP: boolean };
|
|
119
|
+
/** Loop mode changed */
|
|
120
|
+
loopChange: { isLoopEnabled: boolean };
|
|
121
|
+
/** Playback rate changed */
|
|
122
|
+
speedChange: { rate: number };
|
|
123
|
+
/** User skipped forward */
|
|
124
|
+
skipForward: { seconds: number };
|
|
125
|
+
/** User skipped backward */
|
|
126
|
+
skipBackward: { seconds: number };
|
|
127
|
+
/** Speed hold started (hold-for-2x gesture) */
|
|
128
|
+
holdSpeedStart: { speed: number };
|
|
129
|
+
/** Speed hold ended */
|
|
130
|
+
holdSpeedEnd: void;
|
|
131
|
+
/** Captions/subtitles toggled */
|
|
132
|
+
captionsChange: { enabled: boolean };
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// Seeking & Live State Events (Centralized from wrappers)
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
/** Seeking/live state changed - emitted on timeupdate when values change */
|
|
139
|
+
seekingStateChange: {
|
|
140
|
+
seekableStart: number;
|
|
141
|
+
liveEdge: number;
|
|
142
|
+
canSeek: boolean;
|
|
143
|
+
isNearLive: boolean;
|
|
144
|
+
isLive: boolean;
|
|
145
|
+
isWebRTC: boolean;
|
|
146
|
+
latencyTier: LatencyTier;
|
|
147
|
+
buffered: TimeRanges | null;
|
|
148
|
+
hasAudio: boolean;
|
|
149
|
+
supportsPlaybackRate: boolean;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// Interaction Events (Phase A5)
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/** User started hovering over player */
|
|
157
|
+
hoverStart: void;
|
|
158
|
+
/** User stopped hovering (after timeout) */
|
|
159
|
+
hoverEnd: void;
|
|
160
|
+
/** User became idle (no interaction for N seconds) */
|
|
161
|
+
interactionIdle: void;
|
|
162
|
+
/** User resumed interaction after being idle */
|
|
163
|
+
interactionActive: void;
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Metadata Events (Phase A5)
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
/** Playback metadata updated */
|
|
170
|
+
metadataUpdate: {
|
|
171
|
+
currentTime: number;
|
|
172
|
+
duration: number;
|
|
173
|
+
bufferedAhead: number;
|
|
174
|
+
qualityScore?: number;
|
|
175
|
+
playerInfo?: { name: string; shortname: string };
|
|
176
|
+
sourceInfo?: { url: string; type: string };
|
|
177
|
+
isLive: boolean;
|
|
178
|
+
isBuffering: boolean;
|
|
179
|
+
isPaused: boolean;
|
|
180
|
+
volume: number;
|
|
181
|
+
muted: boolean;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// MistServer Source Type Mapping
|
|
187
|
+
// ============================================================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Complete MistServer source type mapping
|
|
191
|
+
* Maps MistServer's `source[].type` field to player selection info
|
|
192
|
+
*
|
|
193
|
+
* type field = MIME type used for player selection
|
|
194
|
+
* hrn = human readable name for UI
|
|
195
|
+
* player = recommended player implementation
|
|
196
|
+
* supported = whether we have a working player for it
|
|
197
|
+
*/
|
|
198
|
+
export const MIST_SOURCE_TYPES: Record<string, { hrn: string; player: string; supported: boolean }> = {
|
|
199
|
+
// ===== VIDEO STREAMING (Primary) =====
|
|
200
|
+
'html5/application/vnd.apple.mpegurl': { hrn: 'HLS (TS)', player: 'hlsjs', supported: true },
|
|
201
|
+
'html5/application/vnd.apple.mpegurl;version=7': { hrn: 'HLS (CMAF)', player: 'hlsjs', supported: true },
|
|
202
|
+
'dash/video/mp4': { hrn: 'DASH', player: 'dashjs', supported: true },
|
|
203
|
+
'html5/video/mp4': { hrn: 'MP4 progressive', player: 'native', supported: true },
|
|
204
|
+
'html5/video/webm': { hrn: 'WebM progressive', player: 'native', supported: true },
|
|
205
|
+
|
|
206
|
+
// ===== WEBSOCKET STREAMING =====
|
|
207
|
+
'ws/video/mp4': { hrn: 'MP4 WebSocket', player: 'mews', supported: true },
|
|
208
|
+
'wss/video/mp4': { hrn: 'MP4 WebSocket (SSL)', player: 'mews', supported: true },
|
|
209
|
+
'ws/video/raw': { hrn: 'Raw WebSocket', player: 'webcodecs', supported: true },
|
|
210
|
+
'wss/video/raw': { hrn: 'Raw WebSocket (SSL)', player: 'webcodecs', supported: true },
|
|
211
|
+
'ws/video/h264': { hrn: 'Annex B WebSocket', player: 'webcodecs', supported: true },
|
|
212
|
+
'wss/video/h264': { hrn: 'Annex B WebSocket (SSL)', player: 'webcodecs', supported: true },
|
|
213
|
+
|
|
214
|
+
// ===== WEBRTC =====
|
|
215
|
+
'whep': { hrn: 'WebRTC (WHEP)', player: 'native', supported: true },
|
|
216
|
+
'webrtc': { hrn: 'WebRTC (WebSocket)', player: 'mist-webrtc', supported: true },
|
|
217
|
+
|
|
218
|
+
// ===== AUDIO ONLY =====
|
|
219
|
+
'html5/audio/aac': { hrn: 'AAC progressive', player: 'native', supported: true },
|
|
220
|
+
'html5/audio/mp3': { hrn: 'MP3 progressive', player: 'native', supported: true },
|
|
221
|
+
'html5/audio/flac': { hrn: 'FLAC progressive', player: 'native', supported: true },
|
|
222
|
+
'html5/audio/wav': { hrn: 'WAV progressive', player: 'native', supported: true },
|
|
223
|
+
|
|
224
|
+
// ===== SUBTITLES/TEXT =====
|
|
225
|
+
'html5/text/vtt': { hrn: 'WebVTT subtitles', player: 'track', supported: true },
|
|
226
|
+
'html5/text/plain': { hrn: 'SRT subtitles', player: 'track', supported: true },
|
|
227
|
+
|
|
228
|
+
// ===== IMAGES =====
|
|
229
|
+
'html5/image/jpeg': { hrn: 'JPEG thumbnail', player: 'image', supported: true },
|
|
230
|
+
|
|
231
|
+
// ===== METADATA =====
|
|
232
|
+
'html5/text/javascript': { hrn: 'JSON metadata', player: 'fetch', supported: true },
|
|
233
|
+
|
|
234
|
+
// ===== LEGACY/UNSUPPORTED =====
|
|
235
|
+
'html5/video/mpeg': { hrn: 'TS progressive', player: 'none', supported: false },
|
|
236
|
+
'html5/video/h264': { hrn: 'Annex B progressive', player: 'none', supported: false },
|
|
237
|
+
'html5/application/sdp': { hrn: 'SDP', player: 'none', supported: false },
|
|
238
|
+
'html5/application/vnd.ms-sstr+xml': { hrn: 'Smooth Streaming', player: 'none', supported: false },
|
|
239
|
+
'flash/7': { hrn: 'FLV', player: 'none', supported: false },
|
|
240
|
+
'flash/10': { hrn: 'RTMP', player: 'none', supported: false },
|
|
241
|
+
'flash/11': { hrn: 'HDS', player: 'none', supported: false },
|
|
242
|
+
|
|
243
|
+
// ===== SERVER-SIDE ONLY =====
|
|
244
|
+
'rtsp': { hrn: 'RTSP', player: 'none', supported: false },
|
|
245
|
+
'srt': { hrn: 'SRT', player: 'none', supported: false },
|
|
246
|
+
'dtsc': { hrn: 'DTSC', player: 'none', supported: false },
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Map Gateway protocol names to MistServer MIME types
|
|
251
|
+
* Gateway outputs use simplified protocol names like "HLS", "WHEP"
|
|
252
|
+
* while MistServer uses full MIME types
|
|
253
|
+
*/
|
|
254
|
+
export const PROTOCOL_TO_MIME: Record<string, string> = {
|
|
255
|
+
// Standard protocols
|
|
256
|
+
'HLS': 'html5/application/vnd.apple.mpegurl',
|
|
257
|
+
'DASH': 'dash/video/mp4',
|
|
258
|
+
'MP4': 'html5/video/mp4',
|
|
259
|
+
'WEBM': 'html5/video/webm',
|
|
260
|
+
'WHEP': 'whep',
|
|
261
|
+
'WebRTC': 'webrtc',
|
|
262
|
+
|
|
263
|
+
// WebSocket variants
|
|
264
|
+
'MEWS': 'ws/video/mp4',
|
|
265
|
+
'MEWS_WS': 'ws/video/mp4',
|
|
266
|
+
'MEWS_WSS': 'wss/video/mp4',
|
|
267
|
+
'RAW_WS': 'ws/video/raw',
|
|
268
|
+
'RAW_WSS': 'wss/video/raw',
|
|
269
|
+
|
|
270
|
+
// Audio
|
|
271
|
+
'AAC': 'html5/audio/aac',
|
|
272
|
+
'MP3': 'html5/audio/mp3',
|
|
273
|
+
'FLAC': 'html5/audio/flac',
|
|
274
|
+
'WAV': 'html5/audio/wav',
|
|
275
|
+
|
|
276
|
+
// Subtitles
|
|
277
|
+
'VTT': 'html5/text/vtt',
|
|
278
|
+
'SRT': 'html5/text/plain',
|
|
279
|
+
|
|
280
|
+
// CMAF variants
|
|
281
|
+
'CMAF': 'html5/application/vnd.apple.mpegurl;version=7',
|
|
282
|
+
'HLS_CMAF': 'html5/application/vnd.apple.mpegurl;version=7',
|
|
283
|
+
|
|
284
|
+
// Images
|
|
285
|
+
'JPEG': 'html5/image/jpeg',
|
|
286
|
+
'JPG': 'html5/image/jpeg',
|
|
287
|
+
|
|
288
|
+
// MistServer specific
|
|
289
|
+
'HTTP': 'html5/video/mp4', // Default HTTP is MP4
|
|
290
|
+
'MIST_HTML': 'mist/html',
|
|
291
|
+
'PLAYER_JS': 'mist/html',
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get the MIME type for a Gateway protocol name
|
|
296
|
+
*/
|
|
297
|
+
export function getMimeTypeForProtocol(protocol: string): string {
|
|
298
|
+
return PROTOCOL_TO_MIME[protocol] || PROTOCOL_TO_MIME[protocol.toUpperCase()] || protocol;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get source type info for a MIME type
|
|
303
|
+
*/
|
|
304
|
+
export function getSourceTypeInfo(mimeType: string): { hrn: string; player: string; supported: boolean } | undefined {
|
|
305
|
+
return MIST_SOURCE_TYPES[mimeType];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// Helper Functions
|
|
310
|
+
// ============================================================================
|
|
311
|
+
|
|
312
|
+
function mapCodecLabel(codecstr: string): string {
|
|
313
|
+
const c = codecstr.toLowerCase();
|
|
314
|
+
if (c.startsWith('avc1')) return 'H264';
|
|
315
|
+
if (c.startsWith('hev1') || c.startsWith('hvc1')) return 'HEVC';
|
|
316
|
+
if (c.startsWith('av01')) return 'AV1';
|
|
317
|
+
if (c.startsWith('vp09')) return 'VP9';
|
|
318
|
+
if (c.startsWith('vp8')) return 'VP8';
|
|
319
|
+
if (c.startsWith('mp4a')) return 'AAC';
|
|
320
|
+
if (c.includes('opus')) return 'Opus';
|
|
321
|
+
if (c.includes('ec-3') || c.includes('ac3')) return 'AC3';
|
|
322
|
+
return codecstr;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Standalone Stream Info Builder
|
|
327
|
+
// ============================================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build StreamInfo from Gateway ContentEndpoints.
|
|
331
|
+
*
|
|
332
|
+
* This function extracts playback sources and track information from
|
|
333
|
+
* the Gateway's resolved endpoint data. It handles:
|
|
334
|
+
* - Parsing `outputs` JSON string (GraphQL returns JSON scalar as string)
|
|
335
|
+
* - Converting output protocols to StreamSource format
|
|
336
|
+
* - Deriving track info from capabilities
|
|
337
|
+
*
|
|
338
|
+
* Use this for VOD/clip content where Gateway data is sufficient,
|
|
339
|
+
* without waiting for MistServer to load the stream.
|
|
340
|
+
*
|
|
341
|
+
* @param endpoints - ContentEndpoints from Gateway resolution
|
|
342
|
+
* @param contentId - Stream/content identifier
|
|
343
|
+
* @returns StreamInfo with sources and tracks, or null if no valid data
|
|
344
|
+
*/
|
|
345
|
+
export function buildStreamInfoFromEndpoints(
|
|
346
|
+
endpoints: ContentEndpoints,
|
|
347
|
+
contentId: string
|
|
348
|
+
): StreamInfo | null {
|
|
349
|
+
const primary = endpoints.primary as EndpointInfo | undefined;
|
|
350
|
+
if (!primary) return null;
|
|
351
|
+
|
|
352
|
+
// Parse outputs if it's a JSON string (GraphQL returns JSON scalar as string)
|
|
353
|
+
let outputs: Record<string, OutputEndpoint> = {};
|
|
354
|
+
if (primary.outputs) {
|
|
355
|
+
if (typeof primary.outputs === 'string') {
|
|
356
|
+
try {
|
|
357
|
+
outputs = JSON.parse(primary.outputs);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
console.warn('[buildStreamInfoFromEndpoints] Failed to parse outputs JSON');
|
|
360
|
+
outputs = {};
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
outputs = primary.outputs as Record<string, OutputEndpoint>;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const sources: StreamSource[] = [];
|
|
368
|
+
const oKeys = Object.keys(outputs);
|
|
369
|
+
|
|
370
|
+
// Helper to attach MistServer sources
|
|
371
|
+
const attachMistSource = (html?: string, playerJs?: string) => {
|
|
372
|
+
if (!html && !playerJs) return;
|
|
373
|
+
const src: StreamSource = {
|
|
374
|
+
url: html || playerJs || '',
|
|
375
|
+
type: 'mist/html',
|
|
376
|
+
streamName: contentId,
|
|
377
|
+
} as StreamSource;
|
|
378
|
+
if (playerJs) {
|
|
379
|
+
(src as any).mistPlayerUrl = playerJs;
|
|
380
|
+
}
|
|
381
|
+
sources.push(src);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (oKeys.length) {
|
|
385
|
+
const html = outputs['MIST_HTML']?.url;
|
|
386
|
+
const pjs = outputs['PLAYER_JS']?.url;
|
|
387
|
+
attachMistSource(html, pjs);
|
|
388
|
+
|
|
389
|
+
// Process all outputs using PROTOCOL_TO_MIME mapping
|
|
390
|
+
// Skip MIST_HTML and PLAYER_JS (already handled above)
|
|
391
|
+
const skipProtocols = new Set(['MIST_HTML', 'PLAYER_JS']);
|
|
392
|
+
|
|
393
|
+
for (const protocol of oKeys) {
|
|
394
|
+
if (skipProtocols.has(protocol)) continue;
|
|
395
|
+
|
|
396
|
+
const output = outputs[protocol];
|
|
397
|
+
if (!output?.url) continue;
|
|
398
|
+
|
|
399
|
+
// Convert Gateway protocol name to MistServer MIME type
|
|
400
|
+
const mimeType = getMimeTypeForProtocol(protocol);
|
|
401
|
+
|
|
402
|
+
// Check if this source type is supported
|
|
403
|
+
const sourceInfo = getSourceTypeInfo(mimeType);
|
|
404
|
+
if (sourceInfo && !sourceInfo.supported) {
|
|
405
|
+
// Skip unsupported source types
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
sources.push({ url: output.url, type: mimeType });
|
|
410
|
+
}
|
|
411
|
+
} else if (primary) {
|
|
412
|
+
// Fallback: single primary URL
|
|
413
|
+
sources.push({
|
|
414
|
+
url: primary.url,
|
|
415
|
+
type: primary.protocol || 'mist/html',
|
|
416
|
+
streamName: contentId,
|
|
417
|
+
} as StreamSource);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Derive tracks from capabilities
|
|
421
|
+
const tracks: StreamTrack[] = [];
|
|
422
|
+
const pushCodecTracks = (cap?: OutputCapabilities) => {
|
|
423
|
+
if (!cap) return;
|
|
424
|
+
const codecs = cap.codecs || [];
|
|
425
|
+
const addTrack = (type: 'video' | 'audio', codecstr: string) => {
|
|
426
|
+
tracks.push({ type, codec: mapCodecLabel(codecstr), codecstring: codecstr });
|
|
427
|
+
};
|
|
428
|
+
codecs.forEach((c) => {
|
|
429
|
+
const lc = c.toLowerCase();
|
|
430
|
+
if (
|
|
431
|
+
lc.startsWith('avc1') ||
|
|
432
|
+
lc.startsWith('hev1') ||
|
|
433
|
+
lc.startsWith('hvc1') ||
|
|
434
|
+
lc.startsWith('vp') ||
|
|
435
|
+
lc.startsWith('av01')
|
|
436
|
+
) {
|
|
437
|
+
addTrack('video', c);
|
|
438
|
+
} else if (
|
|
439
|
+
lc.startsWith('mp4a') ||
|
|
440
|
+
lc.includes('opus') ||
|
|
441
|
+
lc.includes('vorbis') ||
|
|
442
|
+
lc.includes('ac3') ||
|
|
443
|
+
lc.includes('ec-3')
|
|
444
|
+
) {
|
|
445
|
+
addTrack('audio', c);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
if (!codecs.length) {
|
|
449
|
+
// Fallback codecs with valid codecstrings for cold-start playback
|
|
450
|
+
if (cap.hasVideo) tracks.push({ type: 'video', codec: 'H264', codecstring: 'avc1.42E01E' });
|
|
451
|
+
if (cap.hasAudio) tracks.push({ type: 'audio', codec: 'AAC', codecstring: 'mp4a.40.2' });
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
Object.values(outputs).forEach((out) => pushCodecTracks(out.capabilities));
|
|
455
|
+
if (!tracks.length) {
|
|
456
|
+
// Fallback with valid codecstring for cold-start playback
|
|
457
|
+
tracks.push({ type: 'video', codec: 'H264', codecstring: 'avc1.42E01E' });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Determine content type from metadata
|
|
461
|
+
const contentType: 'live' | 'vod' = endpoints.metadata?.isLive === false ? 'vod' : 'live';
|
|
462
|
+
|
|
463
|
+
return sources.length ? { source: sources, meta: { tracks }, type: contentType } : null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ============================================================================
|
|
467
|
+
// PlayerController Class
|
|
468
|
+
// ============================================================================
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Headless player controller that manages the entire player lifecycle.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```typescript
|
|
475
|
+
* const controller = new PlayerController({
|
|
476
|
+
* contentId: 'my-stream',
|
|
477
|
+
* contentType: 'live',
|
|
478
|
+
* gatewayUrl: 'https://gateway.example.com/graphql',
|
|
479
|
+
* });
|
|
480
|
+
*
|
|
481
|
+
* controller.on('stateChange', ({ state }) => console.log('State:', state));
|
|
482
|
+
* controller.on('ready', ({ videoElement }) => console.log('Ready!'));
|
|
483
|
+
*
|
|
484
|
+
* const container = document.getElementById('player');
|
|
485
|
+
* await controller.attach(container);
|
|
486
|
+
*
|
|
487
|
+
* // Later...
|
|
488
|
+
* controller.destroy();
|
|
489
|
+
* ```
|
|
490
|
+
*/
|
|
491
|
+
export class PlayerController extends TypedEventEmitter<PlayerControllerEvents> {
|
|
492
|
+
private config: PlayerControllerConfig;
|
|
493
|
+
private state: PlayerState = 'booting';
|
|
494
|
+
private lastEmittedState: PlayerState | null = null;
|
|
495
|
+
private suppressPlayPauseEventsUntil = 0;
|
|
496
|
+
private suppressPlayPauseEventsUntil = 0;
|
|
497
|
+
|
|
498
|
+
private gatewayClient: GatewayClient | null = null;
|
|
499
|
+
private streamStateClient: StreamStateClient | null = null;
|
|
500
|
+
private playerManager: PlayerManager;
|
|
501
|
+
|
|
502
|
+
private currentPlayer: IPlayer | null = null;
|
|
503
|
+
private videoElement: HTMLVideoElement | null = null;
|
|
504
|
+
private container: HTMLElement | null = null;
|
|
505
|
+
|
|
506
|
+
private endpoints: ContentEndpoints | null = null;
|
|
507
|
+
private streamInfo: StreamInfo | null = null;
|
|
508
|
+
private streamState: StreamState | null = null;
|
|
509
|
+
/** Tracks parsed from MistServer JSON response (used for direct MistServer mode) */
|
|
510
|
+
private mistTracks: StreamTrack[] | null = null;
|
|
511
|
+
|
|
512
|
+
private cleanupFns: Array<() => void> = [];
|
|
513
|
+
private isDestroyed: boolean = false;
|
|
514
|
+
private isAttached: boolean = false;
|
|
515
|
+
|
|
516
|
+
// ============================================================================
|
|
517
|
+
// Internal State Tracking (Phase A1)
|
|
518
|
+
// ============================================================================
|
|
519
|
+
private _isBuffering: boolean = false;
|
|
520
|
+
private _hasPlaybackStarted: boolean = false;
|
|
521
|
+
private _errorText: string | null = null;
|
|
522
|
+
private _isPassiveError: boolean = false;
|
|
523
|
+
private _isHoldingSpeed: boolean = false;
|
|
524
|
+
private _holdSpeed: number = 2;
|
|
525
|
+
private _isLoopEnabled: boolean = false;
|
|
526
|
+
private _currentPlayerInfo: { name: string; shortname: string } | null = null;
|
|
527
|
+
private _currentSourceInfo: { url: string; type: string } | null = null;
|
|
528
|
+
|
|
529
|
+
// One-shot force options (used once by selectCombo, then cleared)
|
|
530
|
+
private _pendingForceOptions: {
|
|
531
|
+
forcePlayer?: string;
|
|
532
|
+
forceType?: string;
|
|
533
|
+
forceSource?: number;
|
|
534
|
+
} | null = null;
|
|
535
|
+
|
|
536
|
+
// ============================================================================
|
|
537
|
+
// Error Handling State (Phase A3)
|
|
538
|
+
// ============================================================================
|
|
539
|
+
private _errorShownAt: number = 0;
|
|
540
|
+
private _errorCleared: boolean = false;
|
|
541
|
+
private _isTransitioning: boolean = false;
|
|
542
|
+
private _errorCount: number = 0;
|
|
543
|
+
private _lastErrorTime: number = 0;
|
|
544
|
+
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// Stream State Tracking (Phase A4)
|
|
547
|
+
// ============================================================================
|
|
548
|
+
private _prevStreamIsOnline: boolean | undefined = undefined;
|
|
549
|
+
|
|
550
|
+
// ============================================================================
|
|
551
|
+
// Hover/Controls Visibility (Phase A5b)
|
|
552
|
+
// ============================================================================
|
|
553
|
+
private _isHovering: boolean = false;
|
|
554
|
+
private _hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
555
|
+
private static readonly HOVER_HIDE_DELAY_MS = 3000;
|
|
556
|
+
private static readonly HOVER_LEAVE_DELAY_MS = 200;
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Subtitles/Captions (Phase A5b audit)
|
|
560
|
+
// ============================================================================
|
|
561
|
+
private _subtitlesEnabled: boolean = false;
|
|
562
|
+
|
|
563
|
+
// ============================================================================
|
|
564
|
+
// Stall Detection (Phase A5b audit)
|
|
565
|
+
// ============================================================================
|
|
566
|
+
private _stallStartTime: number = 0;
|
|
567
|
+
private static readonly HARD_FAILURE_STALL_THRESHOLD_MS = 30000; // 30 seconds sustained stall
|
|
568
|
+
|
|
569
|
+
// ============================================================================
|
|
570
|
+
// Seeking & Live Detection State (Centralized from wrappers)
|
|
571
|
+
// ============================================================================
|
|
572
|
+
private _seekableStart: number = 0;
|
|
573
|
+
private _liveEdge: number = 0;
|
|
574
|
+
private _canSeek: boolean = false;
|
|
575
|
+
private _isNearLive: boolean = true;
|
|
576
|
+
private _latencyTier: LatencyTier = 'medium';
|
|
577
|
+
private _liveThresholds: LiveThresholds = { exitLive: 15, enterLive: 5 };
|
|
578
|
+
private _buffered: TimeRanges | null = null;
|
|
579
|
+
private _hasAudio: boolean = true;
|
|
580
|
+
private _supportsPlaybackRate: boolean = true;
|
|
581
|
+
private _isWebRTC: boolean = false;
|
|
582
|
+
|
|
583
|
+
// Error handling constants
|
|
584
|
+
private static readonly AUTO_CLEAR_ERROR_DELAY_MS = 2000;
|
|
585
|
+
private static readonly HARD_FAILURE_ERROR_THRESHOLD = 5;
|
|
586
|
+
private static readonly HARD_FAILURE_ERROR_WINDOW_MS = 60000;
|
|
587
|
+
private static readonly FATAL_ERROR_KEYWORDS = [
|
|
588
|
+
'fatal', 'network error', 'media error', 'decode error', 'source not supported'
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
// ============================================================================
|
|
592
|
+
// Sub-Controllers (Phase A2)
|
|
593
|
+
// ============================================================================
|
|
594
|
+
private abrController: ABRController | null = null;
|
|
595
|
+
private interactionController: InteractionController | null = null;
|
|
596
|
+
private mistReporter: MistReporter | null = null;
|
|
597
|
+
private qualityMonitor: QualityMonitor | null = null;
|
|
598
|
+
private metaTrackManager: MetaTrackManager | null = null;
|
|
599
|
+
private _playbackQuality: PlaybackQuality | null = null;
|
|
600
|
+
private bootMs: number = Date.now();
|
|
601
|
+
|
|
602
|
+
constructor(config: PlayerControllerConfig) {
|
|
603
|
+
super();
|
|
604
|
+
this.config = config;
|
|
605
|
+
this.playerManager = config.playerManager || globalPlayerManager;
|
|
606
|
+
|
|
607
|
+
// Load loop state from localStorage
|
|
608
|
+
try {
|
|
609
|
+
if (typeof localStorage !== 'undefined') {
|
|
610
|
+
this._isLoopEnabled = localStorage.getItem('frameworks-player-loop') === 'true';
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
// localStorage not available
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ============================================================================
|
|
618
|
+
// Lifecycle Methods
|
|
619
|
+
// ============================================================================
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Attach to a container element and start the player lifecycle.
|
|
623
|
+
* This is the main entry point after construction.
|
|
624
|
+
*/
|
|
625
|
+
async attach(container: HTMLElement): Promise<void> {
|
|
626
|
+
if (this.isDestroyed) {
|
|
627
|
+
throw new Error('PlayerController is destroyed and cannot be reused');
|
|
628
|
+
}
|
|
629
|
+
if (this.isAttached) {
|
|
630
|
+
this.log('Already attached, detaching first');
|
|
631
|
+
this.detach();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
this.container = container;
|
|
635
|
+
this.isAttached = true;
|
|
636
|
+
this.setState('booting');
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
// Ensure players are registered
|
|
640
|
+
ensurePlayersRegistered();
|
|
641
|
+
|
|
642
|
+
// Step 1: Resolve endpoints
|
|
643
|
+
await this.resolveEndpoints();
|
|
644
|
+
|
|
645
|
+
// Guard against zombie operations (React Strict Mode cleanup)
|
|
646
|
+
if (this.isDestroyed || !this.container) {
|
|
647
|
+
this.log('[attach] Aborted - controller destroyed during endpoint resolution');
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!this.endpoints?.primary) {
|
|
652
|
+
this.setState('no_endpoint', { gatewayStatus: 'error' });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Step 2: Start stream state polling (for live content)
|
|
657
|
+
this.startStreamStatePolling();
|
|
658
|
+
|
|
659
|
+
// Step 3: Build StreamInfo and initialize player
|
|
660
|
+
this.streamInfo = this.buildStreamInfo(this.endpoints);
|
|
661
|
+
|
|
662
|
+
if (!this.streamInfo || this.streamInfo.source.length === 0) {
|
|
663
|
+
this.setState('error', { error: 'No playable sources found' });
|
|
664
|
+
this.emit('error', { error: 'No playable sources found' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Guard again before player init (async boundary)
|
|
669
|
+
if (this.isDestroyed || !this.container) {
|
|
670
|
+
this.log('[attach] Aborted - controller destroyed before player init');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
await this.initializePlayer();
|
|
675
|
+
} catch (error) {
|
|
676
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
677
|
+
this.setState('error', { error: message });
|
|
678
|
+
this.emit('error', { error: message });
|
|
679
|
+
|
|
680
|
+
// Even if initial resolution failed (e.g., stream offline), start polling
|
|
681
|
+
// so we can detect when the stream comes online and re-initialize
|
|
682
|
+
if (this.config.mistUrl && !this.streamStateClient) {
|
|
683
|
+
this.log('[attach] Starting stream polling despite resolution failure');
|
|
684
|
+
this.startStreamStatePolling();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Detach from the current container and clean up resources.
|
|
691
|
+
* The controller can be re-attached to a new container.
|
|
692
|
+
*/
|
|
693
|
+
detach(): void {
|
|
694
|
+
this.cleanup();
|
|
695
|
+
this.clearHoverTimeout();
|
|
696
|
+
this.isAttached = false;
|
|
697
|
+
this.container = null;
|
|
698
|
+
this.endpoints = null;
|
|
699
|
+
this.streamInfo = null;
|
|
700
|
+
this.streamState = null;
|
|
701
|
+
this.videoElement = null;
|
|
702
|
+
this.currentPlayer = null;
|
|
703
|
+
this.lastEmittedState = null;
|
|
704
|
+
this._isHovering = false;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Fully destroy the controller. Cannot be reused after this.
|
|
709
|
+
*/
|
|
710
|
+
destroy(): void {
|
|
711
|
+
if (this.isDestroyed) return;
|
|
712
|
+
|
|
713
|
+
this.detach();
|
|
714
|
+
this.setState('destroyed');
|
|
715
|
+
this.emit('destroyed', undefined as never);
|
|
716
|
+
this.removeAllListeners();
|
|
717
|
+
this.isDestroyed = true;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ============================================================================
|
|
721
|
+
// State Getters
|
|
722
|
+
// ============================================================================
|
|
723
|
+
|
|
724
|
+
/** Get current player state */
|
|
725
|
+
getState(): PlayerState {
|
|
726
|
+
return this.state;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Get current stream state (for live streams) */
|
|
730
|
+
getStreamState(): StreamState | null {
|
|
731
|
+
return this.streamState;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Get resolved endpoints */
|
|
735
|
+
getEndpoints(): ContentEndpoints | null {
|
|
736
|
+
return this.endpoints;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Get content metadata (title, description, duration, etc.) */
|
|
740
|
+
getMetadata(): ContentMetadata | null {
|
|
741
|
+
return this.endpoints?.metadata ?? null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Get stream info (sources + tracks for player selection) */
|
|
745
|
+
getStreamInfo(): StreamInfo | null {
|
|
746
|
+
return this.streamInfo;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Get video element (null if not ready) */
|
|
750
|
+
getVideoElement(): HTMLVideoElement | null {
|
|
751
|
+
return this.videoElement;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Get current player instance */
|
|
755
|
+
getPlayer(): IPlayer | null {
|
|
756
|
+
return this.currentPlayer;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Check if player is ready */
|
|
760
|
+
isReady(): boolean {
|
|
761
|
+
return this.videoElement !== null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ============================================================================
|
|
765
|
+
// Extended State Getters (Phase A1)
|
|
766
|
+
// ============================================================================
|
|
767
|
+
|
|
768
|
+
/** Check if video is currently playing (not paused) */
|
|
769
|
+
isPlaying(): boolean {
|
|
770
|
+
const paused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
771
|
+
return !paused;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Check if currently buffering */
|
|
775
|
+
isBuffering(): boolean {
|
|
776
|
+
return this._isBuffering;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Get current error message (null if no error) */
|
|
780
|
+
getError(): string | null {
|
|
781
|
+
return this._errorText;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Check if error is passive (video still playing despite error) */
|
|
785
|
+
isPassiveError(): boolean {
|
|
786
|
+
return this._isPassiveError;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Check if playback has ever started (for idle screen logic) */
|
|
790
|
+
hasPlaybackStarted(): boolean {
|
|
791
|
+
return this._hasPlaybackStarted;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Check if currently holding for speed boost */
|
|
795
|
+
isHoldingSpeed(): boolean {
|
|
796
|
+
return this._isHoldingSpeed;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Get current hold speed value */
|
|
800
|
+
getHoldSpeed(): number {
|
|
801
|
+
return this._holdSpeed;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Get current player implementation info */
|
|
805
|
+
getCurrentPlayerInfo(): { name: string; shortname: string } | null {
|
|
806
|
+
return this._currentPlayerInfo;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Get current source info (URL and type) */
|
|
810
|
+
getCurrentSourceInfo(): { url: string; type: string } | null {
|
|
811
|
+
return this._currentSourceInfo;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Get current volume (0-1) */
|
|
815
|
+
getVolume(): number {
|
|
816
|
+
return this.videoElement?.volume ?? 1;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Check if loop mode is enabled */
|
|
820
|
+
isLoopEnabled(): boolean {
|
|
821
|
+
return this._isLoopEnabled;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Check if subtitles/captions are enabled */
|
|
825
|
+
isSubtitlesEnabled(): boolean {
|
|
826
|
+
return this._subtitlesEnabled;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Set subtitles/captions enabled state */
|
|
830
|
+
setSubtitlesEnabled(enabled: boolean): void {
|
|
831
|
+
if (this._subtitlesEnabled === enabled) return;
|
|
832
|
+
this._subtitlesEnabled = enabled;
|
|
833
|
+
// Apply to video text tracks if available
|
|
834
|
+
if (this.videoElement) {
|
|
835
|
+
const tracks = this.videoElement.textTracks;
|
|
836
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
837
|
+
const track = tracks[i];
|
|
838
|
+
if (track.kind === 'subtitles' || track.kind === 'captions') {
|
|
839
|
+
track.mode = enabled ? 'showing' : 'hidden';
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
this.emit('captionsChange', { enabled });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Toggle subtitles/captions */
|
|
847
|
+
toggleSubtitles(): void {
|
|
848
|
+
this.setSubtitlesEnabled(!this._subtitlesEnabled);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ============================================================================
|
|
852
|
+
// Seeking & Live State Getters (Centralized from wrappers)
|
|
853
|
+
// ============================================================================
|
|
854
|
+
|
|
855
|
+
/** Get start of seekable range (seconds) */
|
|
856
|
+
getSeekableStart(): number {
|
|
857
|
+
return this._seekableStart;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Get live edge / end of seekable range (seconds) */
|
|
861
|
+
getLiveEdge(): number {
|
|
862
|
+
return this._liveEdge;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Check if seeking is currently available */
|
|
866
|
+
canSeekStream(): boolean {
|
|
867
|
+
return this._canSeek;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Check if playback is near the live edge (for live badge display) */
|
|
871
|
+
isNearLive(): boolean {
|
|
872
|
+
return this._isNearLive;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Get buffered ranges, preferring player override when available */
|
|
876
|
+
getBufferedRanges(): TimeRanges | null {
|
|
877
|
+
if (this.currentPlayer && typeof this.currentPlayer.getBufferedRanges === 'function') {
|
|
878
|
+
return this.currentPlayer.getBufferedRanges();
|
|
879
|
+
}
|
|
880
|
+
return this.videoElement?.buffered ?? null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Get current latency tier based on protocol */
|
|
884
|
+
getLatencyTier(): LatencyTier {
|
|
885
|
+
return this._latencyTier;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Get live thresholds for entering/exiting "LIVE" state */
|
|
889
|
+
getLiveThresholds(): LiveThresholds {
|
|
890
|
+
return this._liveThresholds;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Get buffered time ranges */
|
|
894
|
+
getBuffered(): TimeRanges | null {
|
|
895
|
+
return this._buffered;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Check if stream has audio track */
|
|
899
|
+
hasAudioTrack(): boolean {
|
|
900
|
+
return this._hasAudio;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Check if playback rate adjustment is supported */
|
|
904
|
+
canAdjustPlaybackRate(): boolean {
|
|
905
|
+
return this._supportsPlaybackRate;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Check if source is WebRTC/MediaStream */
|
|
909
|
+
isWebRTCSource(): boolean {
|
|
910
|
+
return this._isWebRTC;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Check if currently in fullscreen mode */
|
|
914
|
+
isFullscreen(): boolean {
|
|
915
|
+
if (typeof document === 'undefined') return false;
|
|
916
|
+
return document.fullscreenElement === this.container;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Check if content is effectively live (live or DVR still recording) */
|
|
920
|
+
isEffectivelyLive(): boolean {
|
|
921
|
+
const { contentType } = this.config;
|
|
922
|
+
const metadata = this.getMetadata();
|
|
923
|
+
return contentType === 'live' || (contentType === 'dvr' && metadata?.dvrStatus === 'recording');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Check if content is strictly live (not DVR/clip/vod) */
|
|
927
|
+
isLive(): boolean {
|
|
928
|
+
return this.config.contentType === 'live';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Check if content needs cold start (VOD-like loading).
|
|
933
|
+
* True for: clips, DVR (recording OR completed) - any stored/VOD content
|
|
934
|
+
* False for: live streams only (real-time MistServer stream)
|
|
935
|
+
* DVR-while-recording needs cold start because MistServer may not be serving the VOD yet
|
|
936
|
+
*/
|
|
937
|
+
needsColdStart(): boolean {
|
|
938
|
+
return this.config.contentType !== 'live';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Check if we should show idle/loading screen.
|
|
943
|
+
* Logic:
|
|
944
|
+
* - For cold start content (VOD/DVR): Show loading only while waiting for Gateway sources
|
|
945
|
+
* - For live streams: Show loading while waiting for MistServer to come online
|
|
946
|
+
* - Never show idle after playback has started (unless explicit error)
|
|
947
|
+
*/
|
|
948
|
+
shouldShowIdleScreen(): boolean {
|
|
949
|
+
// Never show idle after playback has started
|
|
950
|
+
if (this._hasPlaybackStarted) return false;
|
|
951
|
+
|
|
952
|
+
if (this.needsColdStart()) {
|
|
953
|
+
// VOD content (clips, DVR recording or completed): DON'T wait for MistServer
|
|
954
|
+
// Use Gateway sources immediately - MistServer will cold start when player requests
|
|
955
|
+
// Show loading only while waiting for Gateway sources (not MistServer)
|
|
956
|
+
const sources = this.streamInfo?.source ?? [];
|
|
957
|
+
return sources.length === 0;
|
|
958
|
+
} else {
|
|
959
|
+
// Live streams: Wait for MistServer online status
|
|
960
|
+
if (!this.streamState?.isOnline || this.streamState?.status !== 'ONLINE') {
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
// Show loading if no stream info or sources
|
|
964
|
+
if (!this.streamInfo || (this.streamInfo.source?.length ?? 0) === 0) {
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Get the effective content type for playback mode selection.
|
|
974
|
+
* This ensures WHEP/WebRTC gets penalized for VOD content (no seek support)
|
|
975
|
+
* while HLS/MP4 are preferred for clips and completed DVR recordings.
|
|
976
|
+
*/
|
|
977
|
+
getEffectiveContentType(): 'live' | 'vod' {
|
|
978
|
+
return this.isEffectivelyLive() ? 'live' : 'vod';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ============================================================================
|
|
982
|
+
// Hover/Controls Visibility (Phase A5b)
|
|
983
|
+
// ============================================================================
|
|
984
|
+
|
|
985
|
+
/** Check if user is currently hovering over the player */
|
|
986
|
+
isHovering(): boolean {
|
|
987
|
+
return this._isHovering;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Check if controls should be visible.
|
|
992
|
+
* Controls are visible when:
|
|
993
|
+
* - User is hovering over the player
|
|
994
|
+
* - Video is paused
|
|
995
|
+
* - There's an error
|
|
996
|
+
*/
|
|
997
|
+
shouldShowControls(): boolean {
|
|
998
|
+
return this._isHovering || this.isPaused() || this._errorText !== null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Handle mouse enter event - show controls immediately.
|
|
1003
|
+
* Call this from your UI wrapper's onMouseEnter handler.
|
|
1004
|
+
*/
|
|
1005
|
+
handleMouseEnter(): void {
|
|
1006
|
+
this.clearHoverTimeout();
|
|
1007
|
+
if (!this._isHovering) {
|
|
1008
|
+
this._isHovering = true;
|
|
1009
|
+
this.emit('hoverStart', undefined as never);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Handle mouse leave event - hide controls after delay.
|
|
1015
|
+
* Call this from your UI wrapper's onMouseLeave handler.
|
|
1016
|
+
*/
|
|
1017
|
+
handleMouseLeave(): void {
|
|
1018
|
+
this.clearHoverTimeout();
|
|
1019
|
+
this._hoverTimeout = setTimeout(() => {
|
|
1020
|
+
if (this._isHovering) {
|
|
1021
|
+
this._isHovering = false;
|
|
1022
|
+
this.emit('hoverEnd', undefined as never);
|
|
1023
|
+
}
|
|
1024
|
+
}, PlayerController.HOVER_LEAVE_DELAY_MS);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Handle mouse move event - show controls and reset hide timer.
|
|
1029
|
+
* Call this from your UI wrapper's onMouseMove handler.
|
|
1030
|
+
*/
|
|
1031
|
+
handleMouseMove(): void {
|
|
1032
|
+
if (!this._isHovering) {
|
|
1033
|
+
this._isHovering = true;
|
|
1034
|
+
this.emit('hoverStart', undefined as never);
|
|
1035
|
+
}
|
|
1036
|
+
// Reset hide timeout on any movement
|
|
1037
|
+
this.clearHoverTimeout();
|
|
1038
|
+
this._hoverTimeout = setTimeout(() => {
|
|
1039
|
+
if (this._isHovering) {
|
|
1040
|
+
this._isHovering = false;
|
|
1041
|
+
this.emit('hoverEnd', undefined as never);
|
|
1042
|
+
}
|
|
1043
|
+
}, PlayerController.HOVER_HIDE_DELAY_MS);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Handle touch start event - show controls.
|
|
1048
|
+
* Call this from your UI wrapper's onTouchStart handler.
|
|
1049
|
+
*/
|
|
1050
|
+
handleTouchStart(): void {
|
|
1051
|
+
this.handleMouseEnter();
|
|
1052
|
+
// Reset hide timer for touch
|
|
1053
|
+
this.clearHoverTimeout();
|
|
1054
|
+
this._hoverTimeout = setTimeout(() => {
|
|
1055
|
+
if (this._isHovering) {
|
|
1056
|
+
this._isHovering = false;
|
|
1057
|
+
this.emit('hoverEnd', undefined as never);
|
|
1058
|
+
}
|
|
1059
|
+
}, PlayerController.HOVER_HIDE_DELAY_MS);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Clear hover timeout */
|
|
1063
|
+
private clearHoverTimeout(): void {
|
|
1064
|
+
if (this._hoverTimeout) {
|
|
1065
|
+
clearTimeout(this._hoverTimeout);
|
|
1066
|
+
this._hoverTimeout = null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Get current playback rate */
|
|
1071
|
+
getPlaybackRate(): number {
|
|
1072
|
+
return this.videoElement?.playbackRate ?? 1;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Get playback quality metrics from QualityMonitor */
|
|
1076
|
+
getPlaybackQuality(): PlaybackQuality | null {
|
|
1077
|
+
return this._playbackQuality;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Get current ABR mode */
|
|
1081
|
+
getABRMode(): ABRMode {
|
|
1082
|
+
return this.abrController?.getMode() ?? 'auto';
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Set ABR mode at runtime */
|
|
1086
|
+
setABRMode(mode: ABRMode): void {
|
|
1087
|
+
this.abrController?.setMode(mode);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ============================================================================
|
|
1091
|
+
// Playback Control
|
|
1092
|
+
// ============================================================================
|
|
1093
|
+
|
|
1094
|
+
/** Start playback */
|
|
1095
|
+
async play(): Promise<void> {
|
|
1096
|
+
if (this.currentPlayer?.play) {
|
|
1097
|
+
await this.currentPlayer.play();
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (this.videoElement) {
|
|
1101
|
+
await this.videoElement.play();
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Pause playback */
|
|
1106
|
+
pause(): void {
|
|
1107
|
+
if (this.currentPlayer?.pause) {
|
|
1108
|
+
this.currentPlayer.pause();
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
this.videoElement?.pause();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Seek to time */
|
|
1115
|
+
seek(time: number): void {
|
|
1116
|
+
// Use player-specific seek if available (for WebCodecs, MEWS, etc.)
|
|
1117
|
+
if (this.currentPlayer?.seek) {
|
|
1118
|
+
this.currentPlayer.seek(time);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// Fallback to direct video element seek
|
|
1122
|
+
if (this.videoElement) {
|
|
1123
|
+
this.videoElement.currentTime = time;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Set volume (0-1) */
|
|
1128
|
+
setVolume(volume: number): void {
|
|
1129
|
+
if (this.videoElement) {
|
|
1130
|
+
const newVolume = Math.max(0, Math.min(1, volume));
|
|
1131
|
+
this.videoElement.volume = newVolume;
|
|
1132
|
+
this.emit('volumeChange', { volume: newVolume, muted: this.videoElement.muted });
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Set muted state */
|
|
1137
|
+
setMuted(muted: boolean): void {
|
|
1138
|
+
if (this.currentPlayer?.setMuted) {
|
|
1139
|
+
this.currentPlayer.setMuted(muted);
|
|
1140
|
+
} else if (this.videoElement) {
|
|
1141
|
+
this.videoElement.muted = muted;
|
|
1142
|
+
}
|
|
1143
|
+
if (this.videoElement) {
|
|
1144
|
+
this.emit('volumeChange', { volume: this.videoElement.volume, muted });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Set playback rate */
|
|
1149
|
+
setPlaybackRate(rate: number): void {
|
|
1150
|
+
if (this.currentPlayer?.setPlaybackRate) {
|
|
1151
|
+
this.currentPlayer.setPlaybackRate(rate);
|
|
1152
|
+
} else if (this.videoElement) {
|
|
1153
|
+
this.videoElement.playbackRate = rate;
|
|
1154
|
+
}
|
|
1155
|
+
this.emit('speedChange', { rate });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Jump to live edge (for live streams) */
|
|
1159
|
+
jumpToLive(): void {
|
|
1160
|
+
// Try player-specific implementation first (WebCodecs uses server time)
|
|
1161
|
+
if (this.currentPlayer?.jumpToLive) {
|
|
1162
|
+
this.currentPlayer.jumpToLive();
|
|
1163
|
+
const el = this.videoElement;
|
|
1164
|
+
if (el && !isMediaStreamSource(el)) {
|
|
1165
|
+
const target = this._liveEdge;
|
|
1166
|
+
if (Number.isFinite(target) && target > 0) {
|
|
1167
|
+
// Fallback: if player-specific jump doesn't move, seek to computed live edge
|
|
1168
|
+
setTimeout(() => {
|
|
1169
|
+
if (!this.videoElement) return;
|
|
1170
|
+
const current = this.getEffectiveCurrentTime();
|
|
1171
|
+
if (target - current > 1) {
|
|
1172
|
+
try { this.videoElement.currentTime = target; } catch {}
|
|
1173
|
+
}
|
|
1174
|
+
}, 200);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
this._isNearLive = true;
|
|
1178
|
+
this.emitSeekingState();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const el = this.videoElement;
|
|
1183
|
+
if (!el) return;
|
|
1184
|
+
|
|
1185
|
+
// For WebRTC/MediaStream: we're always at live, nothing to do
|
|
1186
|
+
if (isMediaStreamSource(el)) {
|
|
1187
|
+
this._isNearLive = true;
|
|
1188
|
+
this.emitSeekingState();
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Try browser's seekable range first (most reliable for HLS/DASH/MEWS)
|
|
1193
|
+
if (el.seekable && el.seekable.length > 0) {
|
|
1194
|
+
const liveEdge = el.seekable.end(el.seekable.length - 1);
|
|
1195
|
+
if (Number.isFinite(liveEdge) && liveEdge > 0) {
|
|
1196
|
+
el.currentTime = liveEdge;
|
|
1197
|
+
this._isNearLive = true;
|
|
1198
|
+
this.emitSeekingState();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Try our computed live edge (from MistServer metadata)
|
|
1204
|
+
if (this._liveEdge > 0 && Number.isFinite(this._liveEdge)) {
|
|
1205
|
+
el.currentTime = this._liveEdge;
|
|
1206
|
+
this._isNearLive = true;
|
|
1207
|
+
this.emitSeekingState();
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Fallback: seek to duration (for VOD or finite-duration live)
|
|
1212
|
+
if (Number.isFinite(el.duration) && el.duration > 0) {
|
|
1213
|
+
el.currentTime = el.duration;
|
|
1214
|
+
this._isNearLive = true;
|
|
1215
|
+
this.emitSeekingState();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Emit current seeking state */
|
|
1220
|
+
private emitSeekingState(): void {
|
|
1221
|
+
this.emit('seekingStateChange', {
|
|
1222
|
+
seekableStart: this._seekableStart,
|
|
1223
|
+
liveEdge: this._liveEdge,
|
|
1224
|
+
canSeek: this._canSeek,
|
|
1225
|
+
isNearLive: this._isNearLive,
|
|
1226
|
+
isLive: this.isEffectivelyLive(),
|
|
1227
|
+
isWebRTC: this._isWebRTC,
|
|
1228
|
+
latencyTier: this._latencyTier,
|
|
1229
|
+
buffered: this._buffered,
|
|
1230
|
+
hasAudio: this._hasAudio,
|
|
1231
|
+
supportsPlaybackRate: this._supportsPlaybackRate,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Request fullscreen */
|
|
1236
|
+
async requestFullscreen(): Promise<void> {
|
|
1237
|
+
if (this.container) {
|
|
1238
|
+
await this.container.requestFullscreen();
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Request Picture-in-Picture */
|
|
1243
|
+
async requestPiP(): Promise<void> {
|
|
1244
|
+
if (this.currentPlayer?.requestPiP) {
|
|
1245
|
+
await this.currentPlayer.requestPiP();
|
|
1246
|
+
} else if (this.videoElement && 'requestPictureInPicture' in this.videoElement) {
|
|
1247
|
+
await (this.videoElement as any).requestPictureInPicture();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Get available quality levels */
|
|
1252
|
+
getQualities(): Array<{ id: string; label: string; bitrate?: number; width?: number; height?: number; isAuto?: boolean; active?: boolean }> {
|
|
1253
|
+
return this.currentPlayer?.getQualities?.() ?? [];
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Select a quality level */
|
|
1257
|
+
selectQuality(id: string): void {
|
|
1258
|
+
this.currentPlayer?.selectQuality?.(id);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** Get available text tracks */
|
|
1262
|
+
getTextTracks(): Array<{ id: string; label: string; lang?: string; active: boolean }> {
|
|
1263
|
+
return this.currentPlayer?.getTextTracks?.() ?? [];
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Select a text track */
|
|
1267
|
+
selectTextTrack(id: string | null): void {
|
|
1268
|
+
this.currentPlayer?.selectTextTrack?.(id);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private getEffectiveCurrentTime(): number {
|
|
1272
|
+
if (this.currentPlayer && typeof this.currentPlayer.getCurrentTime === 'function') {
|
|
1273
|
+
const t = this.currentPlayer.getCurrentTime();
|
|
1274
|
+
if (Number.isFinite(t)) return t;
|
|
1275
|
+
}
|
|
1276
|
+
return this.videoElement?.currentTime ?? 0;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
private getEffectiveDuration(): number {
|
|
1280
|
+
if (this.currentPlayer && typeof this.currentPlayer.getDuration === 'function') {
|
|
1281
|
+
const d = this.currentPlayer.getDuration();
|
|
1282
|
+
if (Number.isFinite(d) || d === Infinity) return d;
|
|
1283
|
+
}
|
|
1284
|
+
return this.videoElement?.duration ?? NaN;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private getPlayerSeekableRange(): { start: number; end: number } | null {
|
|
1288
|
+
if (this.currentPlayer && typeof this.currentPlayer.getSeekableRange === 'function') {
|
|
1289
|
+
const range = this.currentPlayer.getSeekableRange();
|
|
1290
|
+
if (range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start) {
|
|
1291
|
+
return range;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private getFrameStepSecondsFromTracks(): number | undefined {
|
|
1298
|
+
const tracks = this.streamInfo?.meta?.tracks;
|
|
1299
|
+
if (!tracks || tracks.length === 0) return undefined;
|
|
1300
|
+
const videoTracks = tracks.filter(t => t.type === 'video' && typeof t.fpks === 'number' && t.fpks > 0);
|
|
1301
|
+
if (videoTracks.length === 0) return undefined;
|
|
1302
|
+
const fpks = Math.max(...videoTracks.map(t => t.fpks as number));
|
|
1303
|
+
if (!Number.isFinite(fpks) || fpks <= 0) return undefined;
|
|
1304
|
+
// fpks = frames per kilosecond => frame duration in seconds = 1000 / fpks
|
|
1305
|
+
return 1000 / fpks;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private deriveBufferWindowMsFromTracks(tracks?: Record<string, { firstms?: number; lastms?: number }>): number | undefined {
|
|
1309
|
+
if (!tracks) return undefined;
|
|
1310
|
+
const trackList = Object.values(tracks);
|
|
1311
|
+
if (trackList.length === 0) return undefined;
|
|
1312
|
+
const firstmsValues = trackList.map(t => t.firstms).filter((v): v is number => v !== undefined);
|
|
1313
|
+
const lastmsValues = trackList.map(t => t.lastms).filter((v): v is number => v !== undefined);
|
|
1314
|
+
if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
|
|
1315
|
+
const firstms = Math.max(...firstmsValues);
|
|
1316
|
+
const lastms = Math.min(...lastmsValues);
|
|
1317
|
+
const window = lastms - firstms;
|
|
1318
|
+
if (!Number.isFinite(window) || window <= 0) return undefined;
|
|
1319
|
+
return window;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Get current time */
|
|
1323
|
+
getCurrentTime(): number {
|
|
1324
|
+
return this.getEffectiveCurrentTime();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Get duration */
|
|
1328
|
+
getDuration(): number {
|
|
1329
|
+
return this.getEffectiveDuration();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Check if paused */
|
|
1333
|
+
isPaused(): boolean {
|
|
1334
|
+
return this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** Suppress play/pause-driven UI updates for a short window */
|
|
1338
|
+
suppressPlayPauseEvents(ms: number = 200): void {
|
|
1339
|
+
this.suppressPlayPauseEventsUntil = Date.now() + ms;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** Check if play/pause UI updates should be suppressed */
|
|
1343
|
+
shouldSuppressVideoEvents(): boolean {
|
|
1344
|
+
return Date.now() < this.suppressPlayPauseEventsUntil;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Check if muted */
|
|
1348
|
+
isMuted(): boolean {
|
|
1349
|
+
return this.videoElement?.muted ?? true;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Skip backward by specified seconds (default 10) */
|
|
1353
|
+
skipBack(seconds: number = 10): void {
|
|
1354
|
+
this.seekBy(-seconds);
|
|
1355
|
+
this.emit('skipBackward', { seconds });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Skip forward by specified seconds (default 10) */
|
|
1359
|
+
skipForward(seconds: number = 10): void {
|
|
1360
|
+
this.seekBy(seconds);
|
|
1361
|
+
this.emit('skipForward', { seconds });
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Toggle play/pause */
|
|
1365
|
+
togglePlay(): void {
|
|
1366
|
+
const isPaused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
1367
|
+
if (isPaused) {
|
|
1368
|
+
if (this.currentPlayer?.play) {
|
|
1369
|
+
this.currentPlayer.play().catch(() => {});
|
|
1370
|
+
} else {
|
|
1371
|
+
this.videoElement?.play().catch(() => {});
|
|
1372
|
+
}
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (this.currentPlayer?.pause) {
|
|
1376
|
+
this.currentPlayer.pause();
|
|
1377
|
+
} else {
|
|
1378
|
+
this.videoElement?.pause();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Toggle mute */
|
|
1383
|
+
toggleMute(): void {
|
|
1384
|
+
if (this.videoElement) {
|
|
1385
|
+
this.videoElement.muted = !this.videoElement.muted;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** Seek relative to current position */
|
|
1390
|
+
seekBy(delta: number): void {
|
|
1391
|
+
const currentTime = this.getEffectiveCurrentTime();
|
|
1392
|
+
const duration = this.getEffectiveDuration();
|
|
1393
|
+
const newTime = currentTime + delta;
|
|
1394
|
+
const maxTime = isFinite(duration) ? duration : currentTime + Math.abs(delta);
|
|
1395
|
+
this.seek(Math.max(0, Math.min(maxTime, newTime)));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** Seek to percentage (0-1) of duration */
|
|
1399
|
+
seekPercent(percent: number): void {
|
|
1400
|
+
const duration = this.getEffectiveDuration();
|
|
1401
|
+
if (isFinite(duration)) {
|
|
1402
|
+
this.seek(duration * Math.max(0, Math.min(1, percent)));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Toggle loop mode */
|
|
1407
|
+
toggleLoop(): void {
|
|
1408
|
+
this._isLoopEnabled = !this._isLoopEnabled;
|
|
1409
|
+
if (this.videoElement) {
|
|
1410
|
+
this.videoElement.loop = this._isLoopEnabled;
|
|
1411
|
+
}
|
|
1412
|
+
// Persist to localStorage
|
|
1413
|
+
try {
|
|
1414
|
+
if (typeof localStorage !== 'undefined') {
|
|
1415
|
+
localStorage.setItem('frameworks-player-loop', String(this._isLoopEnabled));
|
|
1416
|
+
}
|
|
1417
|
+
} catch {
|
|
1418
|
+
// localStorage not available
|
|
1419
|
+
}
|
|
1420
|
+
this.emit('loopChange', { isLoopEnabled: this._isLoopEnabled });
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Set loop mode */
|
|
1424
|
+
setLoopEnabled(enabled: boolean): void {
|
|
1425
|
+
if (this._isLoopEnabled === enabled) return;
|
|
1426
|
+
this._isLoopEnabled = enabled;
|
|
1427
|
+
if (this.videoElement) {
|
|
1428
|
+
this.videoElement.loop = enabled;
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
if (typeof localStorage !== 'undefined') {
|
|
1432
|
+
localStorage.setItem('frameworks-player-loop', String(enabled));
|
|
1433
|
+
}
|
|
1434
|
+
} catch {}
|
|
1435
|
+
this.emit('loopChange', { isLoopEnabled: enabled });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** Clear current error */
|
|
1439
|
+
clearError(): void {
|
|
1440
|
+
this._errorText = null;
|
|
1441
|
+
this._isPassiveError = false;
|
|
1442
|
+
this._errorCleared = true;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ============================================================================
|
|
1446
|
+
// Seeking & Live State Update (Centralized from wrappers)
|
|
1447
|
+
// ============================================================================
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Update seeking and live detection state.
|
|
1451
|
+
* Called on timeupdate and progress events.
|
|
1452
|
+
* Emits seekingStateChange event when values change.
|
|
1453
|
+
*/
|
|
1454
|
+
private updateSeekingState(): void {
|
|
1455
|
+
const el = this.videoElement;
|
|
1456
|
+
if (!el) return;
|
|
1457
|
+
|
|
1458
|
+
const currentTime = this.getEffectiveCurrentTime();
|
|
1459
|
+
const duration = this.getEffectiveDuration();
|
|
1460
|
+
const isLive = this.isEffectivelyLive();
|
|
1461
|
+
const sourceType = this._currentSourceInfo?.type;
|
|
1462
|
+
const mistStreamInfo = this.streamState?.streamInfo;
|
|
1463
|
+
|
|
1464
|
+
// Update WebRTC detection
|
|
1465
|
+
const wasWebRTC = this._isWebRTC;
|
|
1466
|
+
this._isWebRTC = isMediaStreamSource(el);
|
|
1467
|
+
|
|
1468
|
+
// Update playback rate support
|
|
1469
|
+
this._supportsPlaybackRate = supportsPlaybackRate(el);
|
|
1470
|
+
|
|
1471
|
+
// Update latency tier based on source type
|
|
1472
|
+
this._latencyTier = sourceType ? getLatencyTier(sourceType) : (this._isWebRTC ? 'ultra-low' : 'medium');
|
|
1473
|
+
|
|
1474
|
+
// Update live thresholds (with buffer window scaling)
|
|
1475
|
+
const bufferWindowMs = mistStreamInfo?.meta?.buffer_window
|
|
1476
|
+
?? this.deriveBufferWindowMsFromTracks(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined);
|
|
1477
|
+
this._liveThresholds = calculateLiveThresholds(sourceType, this._isWebRTC, bufferWindowMs);
|
|
1478
|
+
|
|
1479
|
+
// Calculate seekable range using centralized logic (allow player overrides)
|
|
1480
|
+
const playerRange = this.getPlayerSeekableRange();
|
|
1481
|
+
const allowMediaStreamDvr = isMediaStreamSource(el) &&
|
|
1482
|
+
(bufferWindowMs !== undefined && bufferWindowMs > 0) &&
|
|
1483
|
+
(sourceType !== 'whep' && sourceType !== 'webrtc');
|
|
1484
|
+
const { seekableStart, liveEdge } = playerRange
|
|
1485
|
+
? { seekableStart: playerRange.start, liveEdge: playerRange.end }
|
|
1486
|
+
: calculateSeekableRange({
|
|
1487
|
+
isLive,
|
|
1488
|
+
video: el,
|
|
1489
|
+
mistStreamInfo,
|
|
1490
|
+
currentTime,
|
|
1491
|
+
duration,
|
|
1492
|
+
allowMediaStreamDvr,
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Update can seek - pass player's canSeek if available (e.g., WebCodecs uses server commands)
|
|
1496
|
+
const playerCanSeek = this.currentPlayer && typeof (this.currentPlayer as any).canSeek === 'function'
|
|
1497
|
+
? () => (this.currentPlayer as any).canSeek()
|
|
1498
|
+
: undefined;
|
|
1499
|
+
this._canSeek = canSeekStream({
|
|
1500
|
+
video: el,
|
|
1501
|
+
isLive,
|
|
1502
|
+
duration,
|
|
1503
|
+
bufferWindowMs,
|
|
1504
|
+
playerCanSeek,
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// Update buffered ranges
|
|
1508
|
+
this._buffered = el.buffered.length > 0 ? el.buffered : null;
|
|
1509
|
+
|
|
1510
|
+
// Check if values changed
|
|
1511
|
+
const seekableChanged = this._seekableStart !== seekableStart || this._liveEdge !== liveEdge;
|
|
1512
|
+
const canSeekChanged = this._canSeek !== this._canSeek; // Already updated above
|
|
1513
|
+
|
|
1514
|
+
this._seekableStart = seekableStart;
|
|
1515
|
+
this._liveEdge = liveEdge;
|
|
1516
|
+
|
|
1517
|
+
// Update interaction controller live-only state (allow DVR shortcuts when seekable window exists)
|
|
1518
|
+
const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
|
|
1519
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
1520
|
+
this.interactionController?.updateConfig({
|
|
1521
|
+
isLive: isLiveOnly,
|
|
1522
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
// Update isNearLive using hysteresis
|
|
1526
|
+
if (isLive) {
|
|
1527
|
+
const newIsNearLive = calculateIsNearLive(
|
|
1528
|
+
currentTime,
|
|
1529
|
+
liveEdge,
|
|
1530
|
+
this._liveThresholds,
|
|
1531
|
+
this._isNearLive
|
|
1532
|
+
);
|
|
1533
|
+
if (newIsNearLive !== this._isNearLive) {
|
|
1534
|
+
this._isNearLive = newIsNearLive;
|
|
1535
|
+
}
|
|
1536
|
+
} else {
|
|
1537
|
+
this._isNearLive = true; // Always "at live" for VOD
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Emit event for wrappers to consume
|
|
1541
|
+
// Only emit if something meaningful changed to avoid spam
|
|
1542
|
+
if (seekableChanged || wasWebRTC !== this._isWebRTC) {
|
|
1543
|
+
this.emit('seekingStateChange', {
|
|
1544
|
+
seekableStart: this._seekableStart,
|
|
1545
|
+
liveEdge: this._liveEdge,
|
|
1546
|
+
canSeek: this._canSeek,
|
|
1547
|
+
isNearLive: this._isNearLive,
|
|
1548
|
+
isLive,
|
|
1549
|
+
isWebRTC: this._isWebRTC,
|
|
1550
|
+
latencyTier: this._latencyTier,
|
|
1551
|
+
buffered: this._buffered,
|
|
1552
|
+
hasAudio: this._hasAudio,
|
|
1553
|
+
supportsPlaybackRate: this._supportsPlaybackRate,
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Detect audio tracks on the video element.
|
|
1560
|
+
* Called after video metadata is loaded.
|
|
1561
|
+
*/
|
|
1562
|
+
private detectAudioTracks(): void {
|
|
1563
|
+
const el = this.videoElement;
|
|
1564
|
+
if (!el) return;
|
|
1565
|
+
|
|
1566
|
+
// Check MediaStream audio tracks
|
|
1567
|
+
if (el.srcObject instanceof MediaStream) {
|
|
1568
|
+
const audioTracks = el.srcObject.getAudioTracks();
|
|
1569
|
+
this._hasAudio = audioTracks.length > 0;
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Check HTML5 audio tracks (if available)
|
|
1574
|
+
// audioTracks is only available in some browsers (Safari, Edge)
|
|
1575
|
+
const elWithAudio = el as HTMLVideoElement & { audioTracks?: { length: number } };
|
|
1576
|
+
if (elWithAudio.audioTracks && elWithAudio.audioTracks.length !== undefined) {
|
|
1577
|
+
this._hasAudio = elWithAudio.audioTracks.length > 0;
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Default to true if we can't detect
|
|
1582
|
+
this._hasAudio = true;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// ============================================================================
|
|
1586
|
+
// Error Handling (Phase A3)
|
|
1587
|
+
// ============================================================================
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Attempt to clear error automatically if playback is progressing.
|
|
1591
|
+
* Called on timeupdate, playing, and canplay events.
|
|
1592
|
+
*/
|
|
1593
|
+
private attemptClearError(): void {
|
|
1594
|
+
if (!this._errorText || this._errorCleared) return;
|
|
1595
|
+
|
|
1596
|
+
const now = Date.now();
|
|
1597
|
+
const elapsed = now - this._errorShownAt;
|
|
1598
|
+
|
|
1599
|
+
if (elapsed >= PlayerController.AUTO_CLEAR_ERROR_DELAY_MS) {
|
|
1600
|
+
this._errorCleared = true;
|
|
1601
|
+
this._errorText = null;
|
|
1602
|
+
this._isPassiveError = false;
|
|
1603
|
+
this.log('Error auto-cleared after playback resumed');
|
|
1604
|
+
this.emit('errorCleared', undefined as never);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Check if we should attempt playback fallback due to hard failure.
|
|
1610
|
+
* Returns true if:
|
|
1611
|
+
* - Error count exceeds threshold (5+) within time window (60s)
|
|
1612
|
+
* - Error contains fatal keywords
|
|
1613
|
+
* - Sustained stall for 30+ seconds
|
|
1614
|
+
*/
|
|
1615
|
+
private shouldAttemptFallback(error: string): boolean {
|
|
1616
|
+
const now = Date.now();
|
|
1617
|
+
|
|
1618
|
+
// Track error count within window
|
|
1619
|
+
if (now - this._lastErrorTime > PlayerController.HARD_FAILURE_ERROR_WINDOW_MS) {
|
|
1620
|
+
this._errorCount = 0; // Reset counter if outside window
|
|
1621
|
+
}
|
|
1622
|
+
this._errorCount++;
|
|
1623
|
+
this._lastErrorTime = now;
|
|
1624
|
+
|
|
1625
|
+
// Check for repeated errors (5+ errors within 60s)
|
|
1626
|
+
if (this._errorCount >= PlayerController.HARD_FAILURE_ERROR_THRESHOLD) {
|
|
1627
|
+
this.log(`Hard failure: repeated errors (${this._errorCount})`);
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Check for fatal error keywords
|
|
1632
|
+
const lowerError = error.toLowerCase();
|
|
1633
|
+
for (const keyword of PlayerController.FATAL_ERROR_KEYWORDS) {
|
|
1634
|
+
if (lowerError.includes(keyword)) {
|
|
1635
|
+
this.log(`Hard failure: fatal keyword "${keyword}" detected`);
|
|
1636
|
+
return true;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Check for sustained stall (30+ seconds of continuous buffering)
|
|
1641
|
+
if (this._stallStartTime > 0) {
|
|
1642
|
+
const stallDuration = now - this._stallStartTime;
|
|
1643
|
+
if (stallDuration >= PlayerController.HARD_FAILURE_STALL_THRESHOLD_MS) {
|
|
1644
|
+
this.log(`Hard failure: sustained stall for ${stallDuration}ms`);
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Set error with passive mode support.
|
|
1654
|
+
* - Ignores errors during player transitions
|
|
1655
|
+
* - Marks error as passive if video is still playing
|
|
1656
|
+
* - Attempts automatic fallback on hard failures
|
|
1657
|
+
*/
|
|
1658
|
+
async setPassiveError(error: string): Promise<void> {
|
|
1659
|
+
// Ignore errors during player switching transitions (old player cleanup can fire errors)
|
|
1660
|
+
if (this._isTransitioning) {
|
|
1661
|
+
this.log(`Ignoring error during player transition: ${error}`);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Check if video is still playing (passive error scenario)
|
|
1666
|
+
const video = this.videoElement;
|
|
1667
|
+
const isVideoPlaying = video && !video.paused && video.currentTime > 0;
|
|
1668
|
+
|
|
1669
|
+
// Attempt fallback on hard failures before showing error UI
|
|
1670
|
+
if (this.shouldAttemptFallback(error) && this.playerManager.canAttemptFallback()) {
|
|
1671
|
+
this.log('Attempting playback fallback...');
|
|
1672
|
+
this._isTransitioning = true;
|
|
1673
|
+
|
|
1674
|
+
const fallbackSucceeded = await this.playerManager.tryPlaybackFallback();
|
|
1675
|
+
|
|
1676
|
+
this._isTransitioning = false;
|
|
1677
|
+
|
|
1678
|
+
if (fallbackSucceeded) {
|
|
1679
|
+
// Fallback succeeded - clear error state and reset counters
|
|
1680
|
+
this._errorCount = 0;
|
|
1681
|
+
this._errorText = null;
|
|
1682
|
+
this._isPassiveError = false;
|
|
1683
|
+
this.log('Fallback succeeded');
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
// Fallback failed or exhausted - fall through to show error
|
|
1687
|
+
this.log('Fallback exhausted, showing error UI');
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Set error state
|
|
1691
|
+
this._errorShownAt = Date.now();
|
|
1692
|
+
this._errorCleared = false;
|
|
1693
|
+
this._errorText = error;
|
|
1694
|
+
this._isPassiveError = isVideoPlaying ?? false;
|
|
1695
|
+
|
|
1696
|
+
this.setState('error', { error });
|
|
1697
|
+
this.emit('error', { error });
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Retry playback with fallback to next player/source.
|
|
1702
|
+
* Returns true if a fallback option was available and attempted.
|
|
1703
|
+
*/
|
|
1704
|
+
async retryWithFallback(): Promise<boolean> {
|
|
1705
|
+
if (!this.playerManager.canAttemptFallback()) {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
this._isTransitioning = true;
|
|
1710
|
+
const success = await this.playerManager.tryPlaybackFallback();
|
|
1711
|
+
this._isTransitioning = false;
|
|
1712
|
+
|
|
1713
|
+
if (success) {
|
|
1714
|
+
this._errorCount = 0;
|
|
1715
|
+
this.clearError();
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return success;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Toggle fullscreen */
|
|
1722
|
+
async toggleFullscreen(): Promise<void> {
|
|
1723
|
+
if (typeof document === 'undefined') return;
|
|
1724
|
+
|
|
1725
|
+
if (document.fullscreenElement) {
|
|
1726
|
+
await document.exitFullscreen().catch(() => {});
|
|
1727
|
+
} else if (this.container) {
|
|
1728
|
+
await this.container.requestFullscreen().catch(() => {});
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/** Toggle Picture-in-Picture */
|
|
1733
|
+
async togglePictureInPicture(): Promise<void> {
|
|
1734
|
+
if (typeof document === 'undefined') return;
|
|
1735
|
+
|
|
1736
|
+
if (document.pictureInPictureElement) {
|
|
1737
|
+
await document.exitPictureInPicture().catch(() => {});
|
|
1738
|
+
} else if (this.videoElement && 'requestPictureInPicture' in this.videoElement) {
|
|
1739
|
+
await (this.videoElement as any).requestPictureInPicture().catch(() => {});
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** Check if Picture-in-Picture is supported */
|
|
1744
|
+
isPiPSupported(): boolean {
|
|
1745
|
+
if (typeof document === 'undefined') return false;
|
|
1746
|
+
return document.pictureInPictureEnabled ?? false;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** Check if currently in Picture-in-Picture mode */
|
|
1750
|
+
isPiPActive(): boolean {
|
|
1751
|
+
if (typeof document === 'undefined') return false;
|
|
1752
|
+
return document.pictureInPictureElement === this.videoElement;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ============================================================================
|
|
1756
|
+
// Advanced Control
|
|
1757
|
+
// ============================================================================
|
|
1758
|
+
|
|
1759
|
+
/** Force a retry of the current playback */
|
|
1760
|
+
async retry(): Promise<void> {
|
|
1761
|
+
if (!this.container || !this.streamInfo) return;
|
|
1762
|
+
|
|
1763
|
+
try {
|
|
1764
|
+
this.playerManager.destroy();
|
|
1765
|
+
} catch {
|
|
1766
|
+
// Ignore cleanup errors
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
this.container.innerHTML = '';
|
|
1770
|
+
this.videoElement = null;
|
|
1771
|
+
this.currentPlayer = null;
|
|
1772
|
+
|
|
1773
|
+
try {
|
|
1774
|
+
await this.initializePlayer();
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
const message = error instanceof Error ? error.message : 'Retry failed';
|
|
1777
|
+
this.setState('error', { error: message });
|
|
1778
|
+
this.emit('error', { error: message });
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** Get playback statistics */
|
|
1783
|
+
async getStats(): Promise<unknown> {
|
|
1784
|
+
return this.currentPlayer?.getStats?.();
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/** Get current latency (for live streams) */
|
|
1788
|
+
async getLatency(): Promise<unknown> {
|
|
1789
|
+
return this.currentPlayer?.getLatency?.();
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// ============================================================================
|
|
1793
|
+
// Runtime Configuration (Phase A5)
|
|
1794
|
+
// ============================================================================
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Update configuration at runtime without full re-initialization.
|
|
1798
|
+
* Only certain options can be updated without re-init.
|
|
1799
|
+
*/
|
|
1800
|
+
updateConfig(partialConfig: Partial<Pick<PlayerControllerConfig, 'debug' | 'autoplay' | 'muted'>>): void {
|
|
1801
|
+
if (partialConfig.debug !== undefined) {
|
|
1802
|
+
this.config.debug = partialConfig.debug;
|
|
1803
|
+
}
|
|
1804
|
+
if (partialConfig.autoplay !== undefined) {
|
|
1805
|
+
this.config.autoplay = partialConfig.autoplay;
|
|
1806
|
+
}
|
|
1807
|
+
if (partialConfig.muted !== undefined) {
|
|
1808
|
+
this.config.muted = partialConfig.muted;
|
|
1809
|
+
if (this.videoElement) {
|
|
1810
|
+
this.videoElement.muted = partialConfig.muted;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Force a complete re-initialization with current config.
|
|
1817
|
+
* Stops and re-initializes the entire player.
|
|
1818
|
+
*/
|
|
1819
|
+
async reload(): Promise<void> {
|
|
1820
|
+
if (!this.container || this.isDestroyed) return;
|
|
1821
|
+
|
|
1822
|
+
const container = this.container;
|
|
1823
|
+
this.detach();
|
|
1824
|
+
await this.attach(container);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Select a specific player/source combination (one-shot).
|
|
1829
|
+
* Used by DevModePanel to manually pick a combo.
|
|
1830
|
+
*
|
|
1831
|
+
* Note: This is a ONE-SHOT selection. The force settings are used for
|
|
1832
|
+
* the next initialization only. If that player fails, normal fallback
|
|
1833
|
+
* logic proceeds without the force settings.
|
|
1834
|
+
*/
|
|
1835
|
+
async selectCombo(options: {
|
|
1836
|
+
forcePlayer?: string;
|
|
1837
|
+
forceType?: string;
|
|
1838
|
+
forceSource?: number;
|
|
1839
|
+
}): Promise<void> {
|
|
1840
|
+
const container = this.container;
|
|
1841
|
+
if (!container) return;
|
|
1842
|
+
|
|
1843
|
+
this.log(`[selectCombo] One-shot selection: player=${options.forcePlayer}, type=${options.forceType}, source=${options.forceSource}`);
|
|
1844
|
+
|
|
1845
|
+
// Store as one-shot options (will be cleared after use)
|
|
1846
|
+
this._pendingForceOptions = {
|
|
1847
|
+
forcePlayer: options.forcePlayer,
|
|
1848
|
+
forceType: options.forceType,
|
|
1849
|
+
forceSource: options.forceSource,
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
// Detach and re-attach - initializePlayer will use pending options once
|
|
1853
|
+
this.detach();
|
|
1854
|
+
await this.attach(container);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Set playback mode preference.
|
|
1859
|
+
* Unlike selectCombo, this is a persistent preference that affects scoring.
|
|
1860
|
+
*/
|
|
1861
|
+
setPlaybackMode(mode: 'auto' | 'low-latency' | 'quality' | 'vod'): void {
|
|
1862
|
+
this.config.playbackMode = mode;
|
|
1863
|
+
this.log(`[setPlaybackMode] Mode set to: ${mode}`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* @deprecated Use selectCombo() for one-shot selection or setPlaybackMode() for mode changes.
|
|
1868
|
+
* This method exists for backwards compatibility but may override fallback behavior.
|
|
1869
|
+
*/
|
|
1870
|
+
async setDevModeOptions(options: {
|
|
1871
|
+
forcePlayer?: string;
|
|
1872
|
+
forceType?: string;
|
|
1873
|
+
forceSource?: number;
|
|
1874
|
+
playbackMode?: 'auto' | 'low-latency' | 'quality' | 'vod';
|
|
1875
|
+
}): Promise<void> {
|
|
1876
|
+
// Update playback mode if provided (this is a persistent preference)
|
|
1877
|
+
if (options.playbackMode) {
|
|
1878
|
+
this.setPlaybackMode(options.playbackMode);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Use selectCombo for the force settings (one-shot)
|
|
1882
|
+
if (options.forcePlayer !== undefined || options.forceType !== undefined || options.forceSource !== undefined) {
|
|
1883
|
+
await this.selectCombo({
|
|
1884
|
+
forcePlayer: options.forcePlayer,
|
|
1885
|
+
forceType: options.forceType,
|
|
1886
|
+
forceSource: options.forceSource,
|
|
1887
|
+
});
|
|
1888
|
+
} else if (options.playbackMode) {
|
|
1889
|
+
// Mode-only change, trigger reload
|
|
1890
|
+
const container = this.container;
|
|
1891
|
+
if (container) {
|
|
1892
|
+
this.detach();
|
|
1893
|
+
await this.attach(container);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Get metadata update payload for external consumers.
|
|
1900
|
+
* Combines current state into a single metadata object.
|
|
1901
|
+
*/
|
|
1902
|
+
getMetadataPayload(): PlayerControllerEvents['metadataUpdate'] {
|
|
1903
|
+
const video = this.videoElement;
|
|
1904
|
+
const bufferedAhead = video && video.buffered.length > 0
|
|
1905
|
+
? video.buffered.end(video.buffered.length - 1) - video.currentTime
|
|
1906
|
+
: 0;
|
|
1907
|
+
|
|
1908
|
+
return {
|
|
1909
|
+
currentTime: video?.currentTime ?? 0,
|
|
1910
|
+
duration: video?.duration ?? NaN,
|
|
1911
|
+
bufferedAhead: Math.max(0, bufferedAhead),
|
|
1912
|
+
qualityScore: this._playbackQuality?.score,
|
|
1913
|
+
playerInfo: this._currentPlayerInfo ?? undefined,
|
|
1914
|
+
sourceInfo: this._currentSourceInfo ?? undefined,
|
|
1915
|
+
isLive: this.isEffectivelyLive(),
|
|
1916
|
+
isBuffering: this._isBuffering,
|
|
1917
|
+
isPaused: video?.paused ?? true,
|
|
1918
|
+
volume: video?.volume ?? 1,
|
|
1919
|
+
muted: video?.muted ?? true,
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Emit a metadata update event with current state.
|
|
1925
|
+
* Useful for periodic telemetry/reporting.
|
|
1926
|
+
*/
|
|
1927
|
+
emitMetadataUpdate(): void {
|
|
1928
|
+
this.emit('metadataUpdate', this.getMetadataPayload());
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ============================================================================
|
|
1932
|
+
// Private Methods
|
|
1933
|
+
// ============================================================================
|
|
1934
|
+
|
|
1935
|
+
private async resolveEndpoints(): Promise<void> {
|
|
1936
|
+
const { endpoints, gatewayUrl, mistUrl, contentType, contentId, authToken } = this.config;
|
|
1937
|
+
|
|
1938
|
+
// Priority 1: Use pre-resolved endpoints if provided
|
|
1939
|
+
if (endpoints?.primary) {
|
|
1940
|
+
this.endpoints = endpoints;
|
|
1941
|
+
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Priority 2: Direct MistServer resolution (playground/standalone mode)
|
|
1946
|
+
if (mistUrl) {
|
|
1947
|
+
await this.resolveFromMistServer(mistUrl, contentId);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Priority 3: Gateway resolution
|
|
1952
|
+
if (gatewayUrl) {
|
|
1953
|
+
await this.resolveFromGateway(gatewayUrl, contentType, contentId, authToken);
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
throw new Error('No endpoints provided and no gatewayUrl or mistUrl configured');
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Resolve endpoints directly from MistServer (bypasses Gateway)
|
|
1962
|
+
* Fetches json_{contentId}.js and builds ContentEndpoints from source array
|
|
1963
|
+
*/
|
|
1964
|
+
private async resolveFromMistServer(mistUrl: string, contentId: string): Promise<void> {
|
|
1965
|
+
this.setState('gateway_loading', { gatewayStatus: 'loading' });
|
|
1966
|
+
|
|
1967
|
+
try {
|
|
1968
|
+
const jsonUrl = `${mistUrl.replace(/\/+$/, '')}/json_${encodeURIComponent(contentId)}.js`;
|
|
1969
|
+
this.log(`[resolveFromMistServer] Fetching ${jsonUrl}`);
|
|
1970
|
+
|
|
1971
|
+
const response = await fetch(jsonUrl, { cache: 'no-store' });
|
|
1972
|
+
if (!response.ok) {
|
|
1973
|
+
throw new Error(`MistServer HTTP ${response.status}`);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
const data = await response.json();
|
|
1977
|
+
|
|
1978
|
+
if (data.error) {
|
|
1979
|
+
throw new Error(data.error);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const sources: Array<{ url: string; type: string }> = Array.isArray(data.source) ? data.source : [];
|
|
1983
|
+
if (sources.length === 0) {
|
|
1984
|
+
throw new Error('No sources available from MistServer');
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Build outputs map from all sources
|
|
1988
|
+
const outputs: Record<string, { protocol: string; url: string }> = {};
|
|
1989
|
+
for (const source of sources) {
|
|
1990
|
+
const protocol = this.mapMistTypeToProtocol(source.type);
|
|
1991
|
+
if (!outputs[protocol]) {
|
|
1992
|
+
outputs[protocol] = { protocol, url: source.url };
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Select primary source (prefer HLS/DASH over WebSocket-based)
|
|
1997
|
+
const httpSources = sources.filter(s => !s.url.startsWith('ws://'));
|
|
1998
|
+
const primarySource = httpSources.length > 0
|
|
1999
|
+
? this.selectBestSource(httpSources)
|
|
2000
|
+
: sources[0];
|
|
2001
|
+
|
|
2002
|
+
const primary = {
|
|
2003
|
+
nodeId: `mist-${contentId}`,
|
|
2004
|
+
protocol: this.mapMistTypeToProtocol(primarySource.type),
|
|
2005
|
+
url: primarySource.url,
|
|
2006
|
+
baseUrl: mistUrl,
|
|
2007
|
+
outputs,
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
this.endpoints = { primary, fallbacks: [] };
|
|
2011
|
+
|
|
2012
|
+
// Parse track metadata from MistServer response
|
|
2013
|
+
if (data.meta?.tracks && typeof data.meta.tracks === 'object') {
|
|
2014
|
+
const tracks = this.parseMistTracks(data.meta.tracks);
|
|
2015
|
+
this.mistTracks = tracks.length > 0 ? tracks : null;
|
|
2016
|
+
this.log(`[resolveFromMistServer] Parsed ${tracks.length} tracks from MistServer`);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
2020
|
+
this.log(`[resolveFromMistServer] Resolved: ${primary.protocol} @ ${primary.url}`);
|
|
2021
|
+
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
const message = error instanceof Error ? error.message : 'MistServer resolution failed';
|
|
2024
|
+
this.setState('gateway_error', { gatewayStatus: 'error', error: message });
|
|
2025
|
+
throw error;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* Map MistServer type to protocol identifier
|
|
2031
|
+
*/
|
|
2032
|
+
private mapMistTypeToProtocol(mistType: string): string {
|
|
2033
|
+
// 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
|
+
if (mistType === 'ws/video/raw') return 'RAW_WS';
|
|
2036
|
+
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';
|
|
2039
|
+
if (mistType.includes('webrtc')) return 'MIST_WEBRTC';
|
|
2040
|
+
if (mistType.includes('mpegurl') || mistType.includes('m3u8')) return 'HLS';
|
|
2041
|
+
if (mistType.includes('dash') || mistType.includes('mpd')) return 'DASH';
|
|
2042
|
+
if (mistType.includes('whep')) return 'WHEP';
|
|
2043
|
+
if (mistType.includes('mp4')) return 'MP4';
|
|
2044
|
+
if (mistType.includes('webm')) return 'WEBM';
|
|
2045
|
+
return mistType;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Select best source based on protocol priority
|
|
2050
|
+
*/
|
|
2051
|
+
private selectBestSource(sources: Array<{ url: string; type: string }>): { url: string; type: string } {
|
|
2052
|
+
const priority: Record<string, number> = {
|
|
2053
|
+
HLS: 1, DASH: 2, MP4: 3, WEBM: 4, WHEP: 5, MIST_WEBRTC: 6, MEWS_WS: 99,
|
|
2054
|
+
};
|
|
2055
|
+
return sources.sort((a, b) => {
|
|
2056
|
+
const pa = priority[this.mapMistTypeToProtocol(a.type)] ?? 50;
|
|
2057
|
+
const pb = priority[this.mapMistTypeToProtocol(b.type)] ?? 50;
|
|
2058
|
+
return pa - pb;
|
|
2059
|
+
})[0];
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Resolve endpoints from Gateway GraphQL API
|
|
2064
|
+
*/
|
|
2065
|
+
private async resolveFromGateway(
|
|
2066
|
+
gatewayUrl: string,
|
|
2067
|
+
contentType: ContentType,
|
|
2068
|
+
contentId: string,
|
|
2069
|
+
authToken?: string
|
|
2070
|
+
): Promise<void> {
|
|
2071
|
+
this.setState('gateway_loading', { gatewayStatus: 'loading' });
|
|
2072
|
+
|
|
2073
|
+
this.gatewayClient = new GatewayClient({
|
|
2074
|
+
gatewayUrl,
|
|
2075
|
+
contentType,
|
|
2076
|
+
contentId,
|
|
2077
|
+
authToken,
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// Subscribe to status changes
|
|
2081
|
+
const unsub = this.gatewayClient.on('statusChange', ({ status, error }) => {
|
|
2082
|
+
if (status === 'error') {
|
|
2083
|
+
this.setState('gateway_error', { gatewayStatus: status, error });
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
this.cleanupFns.push(unsub);
|
|
2087
|
+
this.cleanupFns.push(() => this.gatewayClient?.destroy());
|
|
2088
|
+
|
|
2089
|
+
try {
|
|
2090
|
+
this.endpoints = await this.gatewayClient.resolve();
|
|
2091
|
+
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
const message = error instanceof Error ? error.message : 'Gateway resolution failed';
|
|
2094
|
+
this.setState('gateway_error', { gatewayStatus: 'error', error: message });
|
|
2095
|
+
throw error;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
private startStreamStatePolling(): void {
|
|
2100
|
+
const { contentType, contentId, mistUrl } = this.config;
|
|
2101
|
+
|
|
2102
|
+
// Only poll for live-like content. DVR should only poll while recording.
|
|
2103
|
+
if (contentType !== 'live' && contentType !== 'dvr') return;
|
|
2104
|
+
if (contentType === 'dvr') {
|
|
2105
|
+
const dvrStatus = this.getMetadata()?.dvrStatus;
|
|
2106
|
+
if (dvrStatus && dvrStatus !== 'recording') return;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Use endpoint baseUrl if available, otherwise fall back to config.mistUrl
|
|
2110
|
+
// This allows polling to start even when initial endpoint resolution failed
|
|
2111
|
+
const mistBaseUrl = this.endpoints?.primary?.baseUrl || mistUrl;
|
|
2112
|
+
if (!mistBaseUrl) return;
|
|
2113
|
+
|
|
2114
|
+
// Use playback ID from metadata if available
|
|
2115
|
+
const metadata = this.getMetadata();
|
|
2116
|
+
const streamName = metadata?.contentId || contentId;
|
|
2117
|
+
|
|
2118
|
+
// For effectively live content, use WebSocket for real-time updates
|
|
2119
|
+
// For completed VOD content, use HTTP polling only
|
|
2120
|
+
const useWebSocket = this.isEffectivelyLive();
|
|
2121
|
+
const pollInterval = this.isEffectivelyLive() ? 3000 : 5000;
|
|
2122
|
+
|
|
2123
|
+
this.streamStateClient = new StreamStateClient({
|
|
2124
|
+
mistBaseUrl,
|
|
2125
|
+
streamName,
|
|
2126
|
+
useWebSocket,
|
|
2127
|
+
pollInterval,
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
// Subscribe to state changes
|
|
2131
|
+
const unsubState = this.streamStateClient.on('stateChange', ({ state }) => {
|
|
2132
|
+
const wasOnline = this._prevStreamIsOnline;
|
|
2133
|
+
const isNowOnline = state.isOnline;
|
|
2134
|
+
|
|
2135
|
+
this.streamState = state;
|
|
2136
|
+
this._prevStreamIsOnline = isNowOnline;
|
|
2137
|
+
|
|
2138
|
+
// Update track metadata if MistServer provides better data
|
|
2139
|
+
// This handles cold-start: Gateway gives fallback codecs, MistServer gives real ones
|
|
2140
|
+
if (state.streamInfo?.meta?.tracks && this.streamInfo) {
|
|
2141
|
+
const mistTracks = this.parseMistTracks(state.streamInfo.meta.tracks);
|
|
2142
|
+
if (mistTracks.length > 0) {
|
|
2143
|
+
this.streamInfo.meta.tracks = mistTracks;
|
|
2144
|
+
this.log(`[stateChange] Updated ${mistTracks.length} tracks from MistServer`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
this.emit('streamStateChange', { state });
|
|
2149
|
+
|
|
2150
|
+
// Auto-play when stream transitions from offline to online
|
|
2151
|
+
// This handles the case where user is watching IdleScreen and stream comes online
|
|
2152
|
+
if (wasOnline === false && isNowOnline === true && this.isEffectivelyLive()) {
|
|
2153
|
+
this.log('Stream came online, triggering auto-play');
|
|
2154
|
+
if (this.videoElement) {
|
|
2155
|
+
// Player already initialized - just play
|
|
2156
|
+
this.videoElement.play().catch(e =>
|
|
2157
|
+
this.log(`Auto-play on online transition failed: ${e}`)
|
|
2158
|
+
);
|
|
2159
|
+
} else if (this.container && !this.endpoints?.primary) {
|
|
2160
|
+
// Player wasn't initialized because stream was offline - re-attempt full initialization
|
|
2161
|
+
this.log('Stream came online, attempting late initialization');
|
|
2162
|
+
this.initializeLateFromStreamState(state.streamInfo);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
this.cleanupFns.push(unsubState);
|
|
2167
|
+
this.cleanupFns.push(() => this.streamStateClient?.destroy());
|
|
2168
|
+
|
|
2169
|
+
this.streamStateClient.start();
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Initialize player late when stream comes online after initial attach failed.
|
|
2174
|
+
* Uses MistStreamInfo from stream state polling instead of re-fetching.
|
|
2175
|
+
*/
|
|
2176
|
+
private async initializeLateFromStreamState(streamInfo: MistStreamInfo | undefined): Promise<void> {
|
|
2177
|
+
if (!streamInfo?.source || !Array.isArray(streamInfo.source) || streamInfo.source.length === 0) {
|
|
2178
|
+
this.log('[initializeLateFromStreamState] No sources in stream info');
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
if (!this.container || !this.config.mistUrl) {
|
|
2183
|
+
this.log('[initializeLateFromStreamState] Missing container or mistUrl');
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
try {
|
|
2188
|
+
const sources = streamInfo.source;
|
|
2189
|
+
const mistUrl = this.config.mistUrl;
|
|
2190
|
+
const contentId = this.config.contentId;
|
|
2191
|
+
|
|
2192
|
+
// Build outputs map from all sources
|
|
2193
|
+
const outputs: Record<string, { protocol: string; url: string }> = {};
|
|
2194
|
+
for (const source of sources) {
|
|
2195
|
+
const protocol = this.mapMistTypeToProtocol(source.type);
|
|
2196
|
+
if (!outputs[protocol]) {
|
|
2197
|
+
outputs[protocol] = { protocol, url: source.url };
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// Select primary source (prefer HLS/DASH over WebSocket-based)
|
|
2202
|
+
const httpSources = sources.filter(s => !s.url.startsWith('ws://'));
|
|
2203
|
+
const primarySource = httpSources.length > 0
|
|
2204
|
+
? this.selectBestSource(httpSources)
|
|
2205
|
+
: sources[0];
|
|
2206
|
+
|
|
2207
|
+
const primary = {
|
|
2208
|
+
nodeId: `mist-${contentId}`,
|
|
2209
|
+
protocol: this.mapMistTypeToProtocol(primarySource.type),
|
|
2210
|
+
url: primarySource.url,
|
|
2211
|
+
baseUrl: mistUrl,
|
|
2212
|
+
outputs,
|
|
2213
|
+
};
|
|
2214
|
+
|
|
2215
|
+
this.endpoints = { primary, fallbacks: [] };
|
|
2216
|
+
|
|
2217
|
+
// Parse track metadata from stream info
|
|
2218
|
+
if (streamInfo.meta?.tracks && typeof streamInfo.meta.tracks === 'object') {
|
|
2219
|
+
const tracks = this.parseMistTracks(streamInfo.meta.tracks);
|
|
2220
|
+
this.mistTracks = tracks.length > 0 ? tracks : null;
|
|
2221
|
+
this.log(`[initializeLateFromStreamState] Parsed ${tracks.length} tracks`);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
2225
|
+
this.log(`[initializeLateFromStreamState] Built endpoints from stream state: ${primary.protocol}`);
|
|
2226
|
+
|
|
2227
|
+
// Build StreamInfo and initialize player
|
|
2228
|
+
this.streamInfo = this.buildStreamInfo(this.endpoints);
|
|
2229
|
+
|
|
2230
|
+
if (!this.streamInfo || this.streamInfo.source.length === 0) {
|
|
2231
|
+
this.setState('error', { error: 'No playable sources found' });
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
await this.initializePlayer();
|
|
2236
|
+
this.log('[initializeLateFromStreamState] Player initialized successfully');
|
|
2237
|
+
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
const message = error instanceof Error ? error.message : 'Late initialization failed';
|
|
2240
|
+
this.log(`[initializeLateFromStreamState] Failed: ${message}`);
|
|
2241
|
+
this.setState('error', { error: message });
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
private buildStreamInfo(endpoints: ContentEndpoints): StreamInfo | null {
|
|
2246
|
+
// Delegate to standalone exported function
|
|
2247
|
+
const info = buildStreamInfoFromEndpoints(endpoints, this.config.contentId);
|
|
2248
|
+
|
|
2249
|
+
// If we have tracks from direct MistServer resolution, use those instead
|
|
2250
|
+
// (they have accurate codecstring and init data for proper codec detection)
|
|
2251
|
+
if (info && this.mistTracks && this.mistTracks.length > 0) {
|
|
2252
|
+
info.meta.tracks = this.mistTracks;
|
|
2253
|
+
this.log(`[buildStreamInfo] Using ${this.mistTracks.length} tracks from MistServer`);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
return info;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* Parse MistServer track metadata from the tracks object.
|
|
2261
|
+
* MistServer returns tracks as a Record keyed by track name (e.g., "video_H264_800x600_25fps_1").
|
|
2262
|
+
* This converts to our StreamTrack[] format with codecstring and init data.
|
|
2263
|
+
*/
|
|
2264
|
+
private parseMistTracks(tracksObj: Record<string, unknown>): StreamTrack[] {
|
|
2265
|
+
const tracks: StreamTrack[] = [];
|
|
2266
|
+
for (const [, trackData] of Object.entries(tracksObj)) {
|
|
2267
|
+
const t = trackData as Record<string, unknown>;
|
|
2268
|
+
const trackType = t.type as string;
|
|
2269
|
+
if (trackType === 'video' || trackType === 'audio' || trackType === 'meta') {
|
|
2270
|
+
tracks.push({
|
|
2271
|
+
type: trackType,
|
|
2272
|
+
codec: t.codec as string,
|
|
2273
|
+
codecstring: t.codecstring as string | undefined,
|
|
2274
|
+
init: t.init as string | undefined,
|
|
2275
|
+
idx: t.idx as number | undefined,
|
|
2276
|
+
width: t.width as number | undefined,
|
|
2277
|
+
height: t.height as number | undefined,
|
|
2278
|
+
fpks: t.fpks as number | undefined,
|
|
2279
|
+
channels: t.channels as number | undefined,
|
|
2280
|
+
rate: t.rate as number | undefined,
|
|
2281
|
+
size: t.size as number | undefined,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
return tracks;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
private async initializePlayer(): Promise<void> {
|
|
2289
|
+
const container = this.container;
|
|
2290
|
+
const streamInfo = this.streamInfo;
|
|
2291
|
+
|
|
2292
|
+
this.log(`[initializePlayer] Starting - container: ${!!container}, streamInfo: ${!!streamInfo}, sources: ${streamInfo?.source?.length ?? 0}`);
|
|
2293
|
+
|
|
2294
|
+
if (!container || !streamInfo) {
|
|
2295
|
+
throw new Error('Container or streamInfo not available');
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Log source details for debugging
|
|
2299
|
+
this.log(`[initializePlayer] Sources: ${JSON.stringify(streamInfo.source.map(s => ({ type: s.type, url: s.url.slice(0, 60) + '...' })))}`);
|
|
2300
|
+
this.log(`[initializePlayer] Tracks: ${streamInfo.meta.tracks.map(t => `${t.type}:${t.codec}`).join(', ')}`);
|
|
2301
|
+
|
|
2302
|
+
const { autoplay, muted, controls, poster } = this.config;
|
|
2303
|
+
|
|
2304
|
+
// Clear container
|
|
2305
|
+
container.innerHTML = '';
|
|
2306
|
+
|
|
2307
|
+
// Listen for player selection
|
|
2308
|
+
const onSelected = (e: { player: string; source: StreamSource; score: number }) => {
|
|
2309
|
+
// Track current player info
|
|
2310
|
+
const playerImpl = this.playerManager.getRegisteredPlayers().find(
|
|
2311
|
+
p => p.capability.shortname === e.player
|
|
2312
|
+
);
|
|
2313
|
+
if (playerImpl) {
|
|
2314
|
+
this._currentPlayerInfo = {
|
|
2315
|
+
name: playerImpl.capability.name,
|
|
2316
|
+
shortname: playerImpl.capability.shortname,
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Track current source info
|
|
2321
|
+
if (e.source) {
|
|
2322
|
+
this._currentSourceInfo = {
|
|
2323
|
+
url: e.source.url,
|
|
2324
|
+
type: e.source.type,
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
this.setState('connecting', {
|
|
2329
|
+
selectedPlayer: e.player,
|
|
2330
|
+
selectedProtocol: (e.source?.type || '').toString(),
|
|
2331
|
+
endpointUrl: e.source?.url,
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
// Bubble up playerSelected event
|
|
2335
|
+
this.emit('playerSelected', { player: e.player, source: e.source, score: e.score });
|
|
2336
|
+
};
|
|
2337
|
+
try {
|
|
2338
|
+
(this.playerManager as any).on?.('playerSelected', onSelected);
|
|
2339
|
+
} catch {}
|
|
2340
|
+
this.cleanupFns.push(() => {
|
|
2341
|
+
try {
|
|
2342
|
+
(this.playerManager as any).off?.('playerSelected', onSelected);
|
|
2343
|
+
} catch {}
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
this.setState('selecting_player');
|
|
2347
|
+
|
|
2348
|
+
const playerOptions: CorePlayerOptions = {
|
|
2349
|
+
autoplay: autoplay !== false,
|
|
2350
|
+
muted: muted !== false,
|
|
2351
|
+
controls: controls !== false,
|
|
2352
|
+
poster: poster,
|
|
2353
|
+
debug: this.config.debug,
|
|
2354
|
+
onReady: (el) => {
|
|
2355
|
+
// Guard against zombie callbacks after destroy
|
|
2356
|
+
if (this.isDestroyed || !this.container) {
|
|
2357
|
+
this.log('[initializePlayer] onReady callback aborted - controller destroyed');
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
// Defensive: some flows (e.g. failed fallback attempt) can temporarily detach
|
|
2361
|
+
// the current video element from the container while playback continues.
|
|
2362
|
+
// Ensure the element is actually attached for rendering.
|
|
2363
|
+
try {
|
|
2364
|
+
if (this.container && !this.container.contains(el)) {
|
|
2365
|
+
this.log('[initializePlayer] Video element was detached; re-attaching to container');
|
|
2366
|
+
this.container.appendChild(el);
|
|
2367
|
+
}
|
|
2368
|
+
} catch {}
|
|
2369
|
+
this.videoElement = el;
|
|
2370
|
+
this.currentPlayer = this.playerManager.getCurrentPlayer();
|
|
2371
|
+
this.setupVideoEventListeners(el);
|
|
2372
|
+
// Initialize sub-controllers after video is ready
|
|
2373
|
+
this.initializeSubControllers();
|
|
2374
|
+
this.emit('ready', { videoElement: el });
|
|
2375
|
+
},
|
|
2376
|
+
onTimeUpdate: (t) => {
|
|
2377
|
+
if (this.isDestroyed) return;
|
|
2378
|
+
// Defensive: keep video element attached even if some other lifecycle cleared the container.
|
|
2379
|
+
// (Playback can continue even when detached, which looks like "audio only".)
|
|
2380
|
+
try {
|
|
2381
|
+
if (this.container && this.videoElement && !this.container.contains(this.videoElement)) {
|
|
2382
|
+
this.log('[initializePlayer] Video element was detached during playback; re-attaching to container');
|
|
2383
|
+
this.container.appendChild(this.videoElement);
|
|
2384
|
+
}
|
|
2385
|
+
} catch {}
|
|
2386
|
+
this.emit('timeUpdate', {
|
|
2387
|
+
currentTime: this.getEffectiveCurrentTime(),
|
|
2388
|
+
duration: this.getEffectiveDuration(),
|
|
2389
|
+
});
|
|
2390
|
+
},
|
|
2391
|
+
onError: (err) => {
|
|
2392
|
+
if (this.isDestroyed) return;
|
|
2393
|
+
const message = typeof err === 'string' ? err : String(err);
|
|
2394
|
+
// Use setPassiveError for smart error handling with fallback support
|
|
2395
|
+
this.setPassiveError(message);
|
|
2396
|
+
},
|
|
2397
|
+
};
|
|
2398
|
+
|
|
2399
|
+
// Manager options for player selection
|
|
2400
|
+
// Use pending force options (one-shot from selectCombo) if available, otherwise use config
|
|
2401
|
+
const pendingForce = this._pendingForceOptions;
|
|
2402
|
+
this._pendingForceOptions = null; // Clear immediately - one-shot only
|
|
2403
|
+
|
|
2404
|
+
const managerOptions = {
|
|
2405
|
+
// One-shot force options take precedence, then fall back to config
|
|
2406
|
+
forcePlayer: pendingForce?.forcePlayer ?? this.config.forcePlayer,
|
|
2407
|
+
forceType: pendingForce?.forceType ?? this.config.forceType,
|
|
2408
|
+
forceSource: pendingForce?.forceSource ?? this.config.forceSource,
|
|
2409
|
+
// Playback mode is a persistent preference
|
|
2410
|
+
playbackMode: this.config.playbackMode,
|
|
2411
|
+
};
|
|
2412
|
+
|
|
2413
|
+
this.log(`[initializePlayer] Calling playerManager.initializePlayer...`);
|
|
2414
|
+
this.log(`[initializePlayer] Manager options: ${JSON.stringify(managerOptions)} (pending force: ${pendingForce ? 'yes' : 'no'})`);
|
|
2415
|
+
try {
|
|
2416
|
+
await this.playerManager.initializePlayer(container, streamInfo, playerOptions, managerOptions);
|
|
2417
|
+
this.log(`[initializePlayer] Player initialized successfully`);
|
|
2418
|
+
} catch (e) {
|
|
2419
|
+
this.log(`[initializePlayer] Player initialization FAILED: ${e}`);
|
|
2420
|
+
throw e;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
private setupVideoEventListeners(el: HTMLVideoElement): void {
|
|
2425
|
+
// Apply loop setting
|
|
2426
|
+
el.loop = this._isLoopEnabled;
|
|
2427
|
+
|
|
2428
|
+
const onWaiting = () => {
|
|
2429
|
+
this._isBuffering = true;
|
|
2430
|
+
// Start stall timer if not already started
|
|
2431
|
+
if (this._stallStartTime === 0) {
|
|
2432
|
+
this._stallStartTime = Date.now();
|
|
2433
|
+
this.log('Stall started');
|
|
2434
|
+
}
|
|
2435
|
+
this.setState('buffering');
|
|
2436
|
+
};
|
|
2437
|
+
const onPlaying = () => {
|
|
2438
|
+
if (this.shouldSuppressVideoEvents()) return;
|
|
2439
|
+
this._isBuffering = false;
|
|
2440
|
+
this._hasPlaybackStarted = true;
|
|
2441
|
+
// Clear stall timer on successful playback
|
|
2442
|
+
if (this._stallStartTime > 0) {
|
|
2443
|
+
this.log(`Stall cleared after ${Date.now() - this._stallStartTime}ms`);
|
|
2444
|
+
this._stallStartTime = 0;
|
|
2445
|
+
}
|
|
2446
|
+
this.setState('playing');
|
|
2447
|
+
// Attempt to clear error on playback resume
|
|
2448
|
+
this.attemptClearError();
|
|
2449
|
+
};
|
|
2450
|
+
const onCanPlay = () => {
|
|
2451
|
+
this._isBuffering = false;
|
|
2452
|
+
// Clear stall timer on canplay
|
|
2453
|
+
this._stallStartTime = 0;
|
|
2454
|
+
this.setState('playing');
|
|
2455
|
+
// Attempt to clear error on canplay
|
|
2456
|
+
this.attemptClearError();
|
|
2457
|
+
};
|
|
2458
|
+
const onPause = () => {
|
|
2459
|
+
if (this.shouldSuppressVideoEvents()) return;
|
|
2460
|
+
this.setState('paused');
|
|
2461
|
+
};
|
|
2462
|
+
const onEnded = () => this.setState('ended');
|
|
2463
|
+
const onError = () => {
|
|
2464
|
+
const message = el.error ? el.error.message || 'Playback error' : 'Playback error';
|
|
2465
|
+
// Use setPassiveError for smart error handling with fallback support
|
|
2466
|
+
this.setPassiveError(message);
|
|
2467
|
+
};
|
|
2468
|
+
const onTimeUpdate = () => {
|
|
2469
|
+
this.emit('timeUpdate', {
|
|
2470
|
+
currentTime: this.getEffectiveCurrentTime(),
|
|
2471
|
+
duration: this.getEffectiveDuration(),
|
|
2472
|
+
});
|
|
2473
|
+
// Update seeking state (seekable range, isNearLive, etc.)
|
|
2474
|
+
this.updateSeekingState();
|
|
2475
|
+
// Attempt to clear error when playback is progressing
|
|
2476
|
+
if (this.getEffectiveCurrentTime() > 0) {
|
|
2477
|
+
this.attemptClearError();
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
const onDurationChange = () => {
|
|
2481
|
+
this.emit('timeUpdate', {
|
|
2482
|
+
currentTime: this.getEffectiveCurrentTime(),
|
|
2483
|
+
duration: this.getEffectiveDuration(),
|
|
2484
|
+
});
|
|
2485
|
+
// Update seeking state on duration change
|
|
2486
|
+
this.updateSeekingState();
|
|
2487
|
+
};
|
|
2488
|
+
const onProgress = () => {
|
|
2489
|
+
// Update buffered ranges
|
|
2490
|
+
this._buffered = el.buffered;
|
|
2491
|
+
// Recalculate seeking state when buffer updates
|
|
2492
|
+
this.updateSeekingState();
|
|
2493
|
+
};
|
|
2494
|
+
const onLoadedMetadata = () => {
|
|
2495
|
+
// Detect audio tracks and WebRTC source
|
|
2496
|
+
this.detectAudioTracks();
|
|
2497
|
+
this._isWebRTC = isMediaStreamSource(el);
|
|
2498
|
+
this._supportsPlaybackRate = !this._isWebRTC;
|
|
2499
|
+
// Initial seeking state calculation
|
|
2500
|
+
this.updateSeekingState();
|
|
2501
|
+
};
|
|
2502
|
+
|
|
2503
|
+
// Fullscreen change handler
|
|
2504
|
+
const onFullscreenChange = () => {
|
|
2505
|
+
const isFullscreen = document.fullscreenElement === this.container;
|
|
2506
|
+
this.emit('fullscreenChange', { isFullscreen });
|
|
2507
|
+
};
|
|
2508
|
+
|
|
2509
|
+
// PiP change handlers
|
|
2510
|
+
const onEnterPiP = () => this.emit('pipChange', { isPiP: true });
|
|
2511
|
+
const onLeavePiP = () => this.emit('pipChange', { isPiP: false });
|
|
2512
|
+
|
|
2513
|
+
// Volume change handler (for external changes, e.g., via native controls)
|
|
2514
|
+
const onVolumeChange = () => {
|
|
2515
|
+
this.emit('volumeChange', { volume: el.volume, muted: el.muted });
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2518
|
+
el.addEventListener('waiting', onWaiting);
|
|
2519
|
+
el.addEventListener('playing', onPlaying);
|
|
2520
|
+
el.addEventListener('canplay', onCanPlay);
|
|
2521
|
+
el.addEventListener('pause', onPause);
|
|
2522
|
+
el.addEventListener('ended', onEnded);
|
|
2523
|
+
el.addEventListener('error', onError);
|
|
2524
|
+
el.addEventListener('timeupdate', onTimeUpdate);
|
|
2525
|
+
el.addEventListener('durationchange', onDurationChange);
|
|
2526
|
+
el.addEventListener('progress', onProgress);
|
|
2527
|
+
el.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
2528
|
+
el.addEventListener('volumechange', onVolumeChange);
|
|
2529
|
+
el.addEventListener('enterpictureinpicture', onEnterPiP);
|
|
2530
|
+
el.addEventListener('leavepictureinpicture', onLeavePiP);
|
|
2531
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
2532
|
+
|
|
2533
|
+
this.cleanupFns.push(() => {
|
|
2534
|
+
el.removeEventListener('waiting', onWaiting);
|
|
2535
|
+
el.removeEventListener('playing', onPlaying);
|
|
2536
|
+
el.removeEventListener('canplay', onCanPlay);
|
|
2537
|
+
el.removeEventListener('pause', onPause);
|
|
2538
|
+
el.removeEventListener('ended', onEnded);
|
|
2539
|
+
el.removeEventListener('error', onError);
|
|
2540
|
+
el.removeEventListener('timeupdate', onTimeUpdate);
|
|
2541
|
+
el.removeEventListener('durationchange', onDurationChange);
|
|
2542
|
+
el.removeEventListener('progress', onProgress);
|
|
2543
|
+
el.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
2544
|
+
el.removeEventListener('volumechange', onVolumeChange);
|
|
2545
|
+
el.removeEventListener('enterpictureinpicture', onEnterPiP);
|
|
2546
|
+
el.removeEventListener('leavepictureinpicture', onLeavePiP);
|
|
2547
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// ============================================================================
|
|
2552
|
+
// Sub-Controller Initialization (Phase A2)
|
|
2553
|
+
// ============================================================================
|
|
2554
|
+
|
|
2555
|
+
private initializeSubControllers(): void {
|
|
2556
|
+
if (!this.videoElement || !this.container) return;
|
|
2557
|
+
|
|
2558
|
+
// Initialize ABRController
|
|
2559
|
+
this.initializeABRController();
|
|
2560
|
+
|
|
2561
|
+
// Initialize QualityMonitor
|
|
2562
|
+
this.initializeQualityMonitor();
|
|
2563
|
+
|
|
2564
|
+
// Initialize InteractionController
|
|
2565
|
+
this.initializeInteractionController();
|
|
2566
|
+
|
|
2567
|
+
// Initialize MistReporter (needs WebSocket from StreamStateClient)
|
|
2568
|
+
this.initializeMistReporter();
|
|
2569
|
+
|
|
2570
|
+
// Initialize MetaTrackManager
|
|
2571
|
+
this.initializeMetaTrackManager();
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
private initializeABRController(): void {
|
|
2575
|
+
const player = this.currentPlayer;
|
|
2576
|
+
if (!player || !this.videoElement) return;
|
|
2577
|
+
|
|
2578
|
+
this.abrController = new ABRController({
|
|
2579
|
+
options: { mode: 'auto' },
|
|
2580
|
+
getQualities: () => player.getQualities?.() ?? [],
|
|
2581
|
+
selectQuality: (id) => player.selectQuality?.(id),
|
|
2582
|
+
getCurrentQuality: () => {
|
|
2583
|
+
const qualities = player.getQualities?.() ?? [];
|
|
2584
|
+
const currentId = player.getCurrentQuality?.();
|
|
2585
|
+
return qualities.find(q => q.id === currentId) ?? null;
|
|
2586
|
+
},
|
|
2587
|
+
// Wire up bandwidth estimate from player stats
|
|
2588
|
+
getBandwidthEstimate: async () => {
|
|
2589
|
+
if (!this.currentPlayer?.getStats) return 0;
|
|
2590
|
+
try {
|
|
2591
|
+
const stats = await this.currentPlayer.getStats();
|
|
2592
|
+
// HLS.js provides bandwidthEstimate directly
|
|
2593
|
+
if (stats?.bandwidthEstimate) {
|
|
2594
|
+
return stats.bandwidthEstimate;
|
|
2595
|
+
}
|
|
2596
|
+
// DASH.js provides throughput info
|
|
2597
|
+
if (stats?.averageThroughput) {
|
|
2598
|
+
return stats.averageThroughput;
|
|
2599
|
+
}
|
|
2600
|
+
return 0;
|
|
2601
|
+
} catch {
|
|
2602
|
+
return 0;
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
2605
|
+
debug: this.config.debug,
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
this.abrController.start(this.videoElement);
|
|
2609
|
+
this.cleanupFns.push(() => {
|
|
2610
|
+
this.abrController?.stop();
|
|
2611
|
+
this.abrController = null;
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
private initializeQualityMonitor(): void {
|
|
2616
|
+
if (!this.videoElement) return;
|
|
2617
|
+
|
|
2618
|
+
this.qualityMonitor = new QualityMonitor({ sampleInterval: 1000 });
|
|
2619
|
+
this.qualityMonitor.start(this.videoElement);
|
|
2620
|
+
|
|
2621
|
+
// Subscribe to quality updates
|
|
2622
|
+
const handleQualityUpdate = () => {
|
|
2623
|
+
if (this.qualityMonitor) {
|
|
2624
|
+
this._playbackQuality = this.qualityMonitor.getCurrentQuality();
|
|
2625
|
+
|
|
2626
|
+
// Feed quality score to MistReporter
|
|
2627
|
+
if (this.mistReporter && this._playbackQuality) {
|
|
2628
|
+
// Convert 0-100 score to MistPlayer-style 0-2.0 scale
|
|
2629
|
+
const mistScore = this._playbackQuality.score / 100;
|
|
2630
|
+
this.mistReporter.setPlaybackScore(mistScore);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
|
|
2635
|
+
// Sample quality periodically
|
|
2636
|
+
const qualityInterval = setInterval(handleQualityUpdate, 1000);
|
|
2637
|
+
this.cleanupFns.push(() => {
|
|
2638
|
+
clearInterval(qualityInterval);
|
|
2639
|
+
this.qualityMonitor?.stop();
|
|
2640
|
+
this.qualityMonitor = null;
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
private initializeInteractionController(): void {
|
|
2645
|
+
if (!this.container || !this.videoElement) return;
|
|
2646
|
+
|
|
2647
|
+
const isLive = this.isEffectivelyLive();
|
|
2648
|
+
const hasDvrWindow = isLive && Number.isFinite(this._liveEdge) && Number.isFinite(this._seekableStart) && this._liveEdge > this._seekableStart;
|
|
2649
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
2650
|
+
const interactionContainer = (this.container.closest('[data-player-container="true"]') as HTMLElement | null) ?? this.container;
|
|
2651
|
+
|
|
2652
|
+
this.interactionController = new InteractionController({
|
|
2653
|
+
container: interactionContainer,
|
|
2654
|
+
videoElement: this.videoElement,
|
|
2655
|
+
isLive: isLiveOnly,
|
|
2656
|
+
isPaused: () => this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true,
|
|
2657
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
2658
|
+
onFrameStep: (direction, seconds) => {
|
|
2659
|
+
const player = this.currentPlayer ?? this.playerManager.getCurrentPlayer();
|
|
2660
|
+
const playerName = player?.capability?.shortname ?? this._currentPlayerInfo?.shortname ?? 'unknown';
|
|
2661
|
+
const hasFrameStep = typeof player?.frameStep === 'function';
|
|
2662
|
+
this.log(`[interaction] frameStep dir=${direction} player=${playerName} hasFrameStep=${hasFrameStep}`);
|
|
2663
|
+
if (playerName === 'webcodecs') {
|
|
2664
|
+
this.suppressPlayPauseEvents(250);
|
|
2665
|
+
}
|
|
2666
|
+
if (hasFrameStep && player) {
|
|
2667
|
+
player.frameStep(direction, seconds);
|
|
2668
|
+
return true;
|
|
2669
|
+
}
|
|
2670
|
+
return false;
|
|
2671
|
+
},
|
|
2672
|
+
onPlayPause: () => this.togglePlay(),
|
|
2673
|
+
onSeek: (delta) => {
|
|
2674
|
+
// End any speed hold before seeking
|
|
2675
|
+
if (this._isHoldingSpeed) {
|
|
2676
|
+
this._isHoldingSpeed = false;
|
|
2677
|
+
this.emit('holdSpeedEnd', undefined as never);
|
|
2678
|
+
}
|
|
2679
|
+
this.seekBy(delta);
|
|
2680
|
+
// Emit skip events
|
|
2681
|
+
if (delta > 0) {
|
|
2682
|
+
this.emit('skipForward', { seconds: delta });
|
|
2683
|
+
} else {
|
|
2684
|
+
this.emit('skipBackward', { seconds: Math.abs(delta) });
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
onVolumeChange: (delta) => {
|
|
2688
|
+
if (this.videoElement) {
|
|
2689
|
+
const newVolume = Math.max(0, Math.min(1, this.videoElement.volume + delta));
|
|
2690
|
+
this.videoElement.volume = newVolume;
|
|
2691
|
+
this.emit('volumeChange', { volume: newVolume, muted: this.videoElement.muted });
|
|
2692
|
+
}
|
|
2693
|
+
},
|
|
2694
|
+
onMuteToggle: () => this.toggleMute(),
|
|
2695
|
+
onFullscreenToggle: () => this.toggleFullscreen(),
|
|
2696
|
+
onCaptionsToggle: () => {
|
|
2697
|
+
this.toggleSubtitles();
|
|
2698
|
+
},
|
|
2699
|
+
onSpeedChange: (speed, isHolding) => {
|
|
2700
|
+
const wasHolding = this._isHoldingSpeed;
|
|
2701
|
+
this._isHoldingSpeed = isHolding;
|
|
2702
|
+
this._holdSpeed = speed;
|
|
2703
|
+
this.setPlaybackRate(speed);
|
|
2704
|
+
|
|
2705
|
+
// Emit holdSpeed events on state transitions
|
|
2706
|
+
if (isHolding && !wasHolding) {
|
|
2707
|
+
this.emit('holdSpeedStart', { speed });
|
|
2708
|
+
} else if (!isHolding && wasHolding) {
|
|
2709
|
+
this.emit('holdSpeedEnd', undefined as never);
|
|
2710
|
+
}
|
|
2711
|
+
},
|
|
2712
|
+
onSeekPercent: (percent) => this.seekPercent(percent),
|
|
2713
|
+
speedHoldValue: this._holdSpeed,
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
this.interactionController.attach();
|
|
2717
|
+
this.cleanupFns.push(() => {
|
|
2718
|
+
this.interactionController?.detach();
|
|
2719
|
+
this.interactionController = null;
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
private initializeMistReporter(): void {
|
|
2724
|
+
if (!this.streamStateClient) return;
|
|
2725
|
+
|
|
2726
|
+
const socket = this.streamStateClient.getSocket();
|
|
2727
|
+
if (!socket) return;
|
|
2728
|
+
|
|
2729
|
+
this.mistReporter = new MistReporter({
|
|
2730
|
+
socket,
|
|
2731
|
+
bootMs: this.bootMs,
|
|
2732
|
+
reportInterval: 5000,
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
// Initialize with video element
|
|
2736
|
+
if (this.videoElement) {
|
|
2737
|
+
this.mistReporter.init(this.videoElement, this.container ?? undefined);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// Send initial report
|
|
2741
|
+
if (this._currentSourceInfo) {
|
|
2742
|
+
this.mistReporter.sendInitialReport({
|
|
2743
|
+
player: this._currentPlayerInfo?.shortname || 'unknown',
|
|
2744
|
+
sourceType: this._currentSourceInfo.type,
|
|
2745
|
+
sourceUrl: this._currentSourceInfo.url,
|
|
2746
|
+
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
this.cleanupFns.push(() => {
|
|
2751
|
+
this.mistReporter?.sendFinalReport('unmount');
|
|
2752
|
+
this.mistReporter?.destroy();
|
|
2753
|
+
this.mistReporter = null;
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
private initializeMetaTrackManager(): void {
|
|
2758
|
+
const mistUrl = this.endpoints?.primary?.baseUrl;
|
|
2759
|
+
if (!mistUrl) return;
|
|
2760
|
+
|
|
2761
|
+
this.metaTrackManager = new MetaTrackManager({
|
|
2762
|
+
mistBaseUrl: mistUrl,
|
|
2763
|
+
streamName: this.config.contentId,
|
|
2764
|
+
debug: this.config.debug,
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2767
|
+
this.metaTrackManager.connect();
|
|
2768
|
+
|
|
2769
|
+
// Wire video timeupdate to MetaTrackManager
|
|
2770
|
+
if (this.videoElement) {
|
|
2771
|
+
const handleTimeUpdate = () => {
|
|
2772
|
+
if (this.videoElement && this.metaTrackManager) {
|
|
2773
|
+
this.metaTrackManager.setPlaybackTime(this.videoElement.currentTime);
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
const handleSeeking = () => {
|
|
2777
|
+
if (this.videoElement && this.metaTrackManager) {
|
|
2778
|
+
this.metaTrackManager.onSeek(this.videoElement.currentTime);
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
this.videoElement.addEventListener('timeupdate', handleTimeUpdate);
|
|
2783
|
+
this.videoElement.addEventListener('seeking', handleSeeking);
|
|
2784
|
+
|
|
2785
|
+
this.cleanupFns.push(() => {
|
|
2786
|
+
this.videoElement?.removeEventListener('timeupdate', handleTimeUpdate);
|
|
2787
|
+
this.videoElement?.removeEventListener('seeking', handleSeeking);
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
this.cleanupFns.push(() => {
|
|
2792
|
+
this.metaTrackManager?.disconnect();
|
|
2793
|
+
this.metaTrackManager = null;
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
private cleanup(): void {
|
|
2798
|
+
// Run all cleanup functions
|
|
2799
|
+
this.cleanupFns.forEach((fn) => {
|
|
2800
|
+
try {
|
|
2801
|
+
fn();
|
|
2802
|
+
} catch {}
|
|
2803
|
+
});
|
|
2804
|
+
this.cleanupFns = [];
|
|
2805
|
+
|
|
2806
|
+
// Destroy player manager's current player
|
|
2807
|
+
try {
|
|
2808
|
+
this.playerManager.destroy();
|
|
2809
|
+
} catch {}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
private setState(state: PlayerState, context?: PlayerStateContext): void {
|
|
2813
|
+
this.state = state;
|
|
2814
|
+
|
|
2815
|
+
// Only emit if state actually changed
|
|
2816
|
+
if (this.lastEmittedState !== state) {
|
|
2817
|
+
this.lastEmittedState = state;
|
|
2818
|
+
this.emit('stateChange', { state, context });
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
private log(message: string): void {
|
|
2823
|
+
if (this.config.debug) {
|
|
2824
|
+
console.log(`[PlayerController] ${message}`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
export default PlayerController;
|