unified-video-framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.github/workflows/ci.yml +253 -0
  2. package/ANDROID_TV_IMPLEMENTATION.md +313 -0
  3. package/COMPLETION_STATUS.md +165 -0
  4. package/CONTRIBUTING.md +376 -0
  5. package/FINAL_STATUS_REPORT.md +170 -0
  6. package/FRAMEWORK_REVIEW.md +247 -0
  7. package/IMPROVEMENTS_SUMMARY.md +168 -0
  8. package/LICENSE +21 -0
  9. package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
  10. package/PAYWALL_RENTAL_FLOW.md +499 -0
  11. package/PLATFORM_SETUP_GUIDE.md +1636 -0
  12. package/README.md +315 -0
  13. package/RUN_LOCALLY.md +151 -0
  14. package/apps/demo/cast-sender-min.html +173 -0
  15. package/apps/demo/custom-player.html +883 -0
  16. package/apps/demo/demo.html +990 -0
  17. package/apps/demo/enhanced-player.html +3556 -0
  18. package/apps/demo/index.html +159 -0
  19. package/apps/rental-api/.env.example +24 -0
  20. package/apps/rental-api/README.md +23 -0
  21. package/apps/rental-api/migrations/001_init.sql +35 -0
  22. package/apps/rental-api/migrations/002_videos.sql +10 -0
  23. package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
  24. package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
  25. package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
  26. package/apps/rental-api/package-lock.json +2045 -0
  27. package/apps/rental-api/package.json +33 -0
  28. package/apps/rental-api/scripts/run-migration.js +42 -0
  29. package/apps/rental-api/scripts/update-video-currency.js +21 -0
  30. package/apps/rental-api/scripts/update-video-price.js +19 -0
  31. package/apps/rental-api/src/config.ts +14 -0
  32. package/apps/rental-api/src/db.ts +10 -0
  33. package/apps/rental-api/src/routes/cashfree.ts +167 -0
  34. package/apps/rental-api/src/routes/pesapal.ts +92 -0
  35. package/apps/rental-api/src/routes/rentals.ts +242 -0
  36. package/apps/rental-api/src/routes/webhooks.ts +73 -0
  37. package/apps/rental-api/src/server.ts +41 -0
  38. package/apps/rental-api/src/services/entitlements.ts +45 -0
  39. package/apps/rental-api/src/services/payments.ts +22 -0
  40. package/apps/rental-api/tsconfig.json +17 -0
  41. package/check-urls.ps1 +74 -0
  42. package/comparison-report.md +181 -0
  43. package/docs/PAYWALL.md +95 -0
  44. package/docs/PLAYER_UI_VISIBILITY.md +431 -0
  45. package/docs/README.md +7 -0
  46. package/docs/SYSTEM_ARCHITECTURE.md +612 -0
  47. package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
  48. package/examples/android/JavaSampleApp/MainActivity.java +641 -0
  49. package/examples/android/JavaSampleApp/activity_main.xml +226 -0
  50. package/examples/android/SampleApp/MainActivity.kt +430 -0
  51. package/examples/ios/SampleApp/ViewController.swift +337 -0
  52. package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
  53. package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
  54. package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
  55. package/jest.config.js +33 -0
  56. package/jitpack.yml +5 -0
  57. package/lerna.json +35 -0
  58. package/package.json +69 -0
  59. package/packages/PLATFORM_STATUS.md +163 -0
  60. package/packages/android/build.gradle +135 -0
  61. package/packages/android/src/main/AndroidManifest.xml +36 -0
  62. package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
  63. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
  64. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
  65. package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
  66. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
  67. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
  68. package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
  69. package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
  70. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
  71. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
  72. package/packages/core/package.json +34 -0
  73. package/packages/core/src/BasePlayer.ts +250 -0
  74. package/packages/core/src/VideoPlayer.ts +237 -0
  75. package/packages/core/src/VideoPlayerFactory.ts +145 -0
  76. package/packages/core/src/index.ts +20 -0
  77. package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
  78. package/packages/core/src/interfaces.ts +240 -0
  79. package/packages/core/src/utils/EventEmitter.ts +66 -0
  80. package/packages/core/src/utils/PlatformDetector.ts +300 -0
  81. package/packages/core/tsconfig.json +20 -0
  82. package/packages/enact/package.json +51 -0
  83. package/packages/enact/src/VideoPlayer.js +365 -0
  84. package/packages/enact/src/adapters/TizenAdapter.js +354 -0
  85. package/packages/enact/src/index.js +82 -0
  86. package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
  87. package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
  88. package/packages/ios/GETTING_STARTED.md +100 -0
  89. package/packages/ios/Package.swift +35 -0
  90. package/packages/ios/README.md +84 -0
  91. package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
  92. package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
  93. package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
  94. package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
  95. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
  96. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
  97. package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
  98. package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
  99. package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
  100. package/packages/ios/build_framework.sh +55 -0
  101. package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
  102. package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
  103. package/packages/react-native/package.json +51 -0
  104. package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
  105. package/packages/react-native/src/VideoPlayer.tsx +224 -0
  106. package/packages/react-native/src/index.ts +28 -0
  107. package/packages/react-native/src/utils/EventEmitter.ts +66 -0
  108. package/packages/react-native/tsconfig.json +31 -0
  109. package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
  110. package/packages/roku/package.json +44 -0
  111. package/packages/roku/source/VideoPlayer.brs +231 -0
  112. package/packages/roku/source/main.brs +28 -0
  113. package/packages/web/GETTING_STARTED.md +292 -0
  114. package/packages/web/jest.config.js +28 -0
  115. package/packages/web/jest.setup.ts +110 -0
  116. package/packages/web/package.json +50 -0
  117. package/packages/web/src/SecureVideoPlayer.ts +1164 -0
  118. package/packages/web/src/WebPlayer.ts +3110 -0
  119. package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
  120. package/packages/web/src/index.ts +14 -0
  121. package/packages/web/src/paywall/PaywallController.ts +215 -0
  122. package/packages/web/src/react/WebPlayerView.tsx +177 -0
  123. package/packages/web/tsconfig.json +23 -0
  124. package/packages/web/webpack.config.js +45 -0
  125. package/server.js +131 -0
  126. package/server.py +84 -0
  127. package/test-urls.ps1 +97 -0
  128. package/test-video-urls.ps1 +87 -0
  129. package/tsconfig.json +39 -0
@@ -0,0 +1,3110 @@
1
+ /**
2
+ * Web implementation of the video player with HLS and DASH support
3
+ */
4
+
5
+ import { BasePlayer } from '@unified-video/core';
6
+ import {
7
+ VideoSource,
8
+ PlayerConfig,
9
+ Quality,
10
+ SubtitleTrack,
11
+ PlayerError
12
+ } from '@unified-video/core';
13
+
14
+ // Dynamic imports for streaming libraries
15
+ declare global {
16
+ interface Window {
17
+ Hls: any;
18
+ dashjs: any;
19
+ cast?: any;
20
+ chrome?: any;
21
+ __onGCastApiAvailable?: (isAvailable: boolean) => void;
22
+ }
23
+ }
24
+
25
+ export class WebPlayer extends BasePlayer {
26
+ protected video: HTMLVideoElement | null = null;
27
+ private hls: any = null;
28
+ private dash: any = null;
29
+ private qualities: Quality[] = [];
30
+ private currentQualityIndex: number = -1;
31
+ private autoQuality: boolean = true;
32
+ private useCustomControls: boolean = true;
33
+ private controlsContainer: HTMLElement | null = null;
34
+ private hideControlsTimeout: any = null;
35
+ private volumeHideTimeout: any = null;
36
+ private isVolumeSliding: boolean = false;
37
+ private isDragging: boolean = false;
38
+ private watermarkCanvas: HTMLCanvasElement | null = null;
39
+ private playerWrapper: HTMLElement | null = null;
40
+ // Free preview gate state
41
+ private previewGateHit: boolean = false;
42
+
43
+ // Cast state
44
+ private castContext: any = null;
45
+ private remotePlayer: any = null;
46
+ private remoteController: any = null;
47
+ private isCasting: boolean = false;
48
+ private _castTrackIdByKey: Record<string, number> = {};
49
+ private selectedSubtitleKey: string = 'off';
50
+ private _kiTo: any = null;
51
+
52
+ // Paywall
53
+ private paywallController: any = null;
54
+
55
+
56
+ protected async setupPlayer(): Promise<void> {
57
+ if (!this.container) {
58
+ throw new Error('Container element is required');
59
+ }
60
+
61
+ // Inject styles
62
+ this.injectStyles();
63
+
64
+ // Create wrapper
65
+ const wrapper = document.createElement('div');
66
+ wrapper.className = 'uvf-player-wrapper';
67
+ this.playerWrapper = wrapper;
68
+
69
+ // Create video container
70
+ const videoContainer = document.createElement('div');
71
+ videoContainer.className = 'uvf-video-container';
72
+
73
+ // Create video element
74
+ this.video = document.createElement('video');
75
+ this.video.className = 'uvf-video';
76
+ this.video.controls = false; // We'll use custom controls
77
+ this.video.autoplay = this.config.autoPlay ?? false;
78
+ this.video.muted = this.config.muted ?? false;
79
+ this.video.loop = this.config.loop ?? false;
80
+ this.video.playsInline = this.config.playsInline ?? true;
81
+ this.video.preload = this.config.preload ?? 'metadata';
82
+
83
+ if (this.config.crossOrigin) {
84
+ this.video.crossOrigin = this.config.crossOrigin;
85
+ }
86
+
87
+ // Add watermark canvas
88
+ this.watermarkCanvas = document.createElement('canvas');
89
+ this.watermarkCanvas.className = 'uvf-watermark-layer';
90
+
91
+ // Add video to container
92
+ videoContainer.appendChild(this.video);
93
+ videoContainer.appendChild(this.watermarkCanvas);
94
+
95
+ // Create custom controls if enabled
96
+ if (this.useCustomControls) {
97
+ this.createCustomControls(videoContainer);
98
+ }
99
+
100
+ // Assemble the player
101
+ wrapper.appendChild(videoContainer);
102
+
103
+ // Add to container
104
+ this.container.innerHTML = '';
105
+ this.container.appendChild(wrapper);
106
+
107
+ // Apply scrollbar preferences from data attributes, if any
108
+ this.applyScrollbarPreferencesFromDataset();
109
+
110
+ // Setup event listeners
111
+ this.setupVideoEventListeners();
112
+ this.setupControlsEventListeners();
113
+ this.setupKeyboardShortcuts();
114
+ this.setupWatermark();
115
+
116
+ // Initialize paywall controller if provided
117
+ try {
118
+ const pw: any = (this.config as any).paywall || null;
119
+ if (pw && pw.enabled) {
120
+ const { PaywallController } = await import('./paywall/PaywallController');
121
+ this.paywallController = new (PaywallController as any)(pw, {
122
+ getOverlayContainer: () => this.playerWrapper,
123
+ onResume: () => { try { this.play(); } catch(_) {} },
124
+ onShow: () => {},
125
+ onClose: () => {}
126
+ });
127
+ // When free preview ends, open overlay
128
+ this.on('onFreePreviewEnded' as any, () => {
129
+ try { this.paywallController?.openOverlay(); } catch(_) {}
130
+ });
131
+ }
132
+ } catch (_) {}
133
+
134
+ // Attempt to bind Cast context if available
135
+ this.setupCastContextSafe();
136
+
137
+ // Initialize metadata UI to hidden/empty by default
138
+ this.updateMetadataUI();
139
+ }
140
+
141
+ private setupVideoEventListeners(): void {
142
+ if (!this.video) return;
143
+
144
+ this.video.addEventListener('play', () => {
145
+ // Enforce free preview before letting play proceed
146
+ if (this.config.freeDuration && this.config.freeDuration > 0) {
147
+ const lim = Number(this.config.freeDuration);
148
+ const cur = this.video!.currentTime || 0;
149
+ if (!this.previewGateHit && cur >= lim) {
150
+ try { this.video!.pause(); } catch (_) {}
151
+ this.showNotification('Free preview ended. Please rent to continue.');
152
+ return;
153
+ }
154
+ }
155
+ this.state.isPlaying = true;
156
+ this.state.isPaused = false;
157
+ this.emit('onPlay');
158
+ });
159
+
160
+ this.video.addEventListener('pause', () => {
161
+ this.state.isPlaying = false;
162
+ this.state.isPaused = true;
163
+ this.emit('onPause');
164
+ });
165
+
166
+ this.video.addEventListener('ended', () => {
167
+ this.state.isEnded = true;
168
+ this.state.isPlaying = false;
169
+ this.emit('onEnded');
170
+ });
171
+
172
+ this.video.addEventListener('timeupdate', () => {
173
+ const t = this.video!.currentTime;
174
+ this.updateTime(t);
175
+ // Enforce free preview gate on local playback
176
+ this.enforceFreePreviewGate(t);
177
+ });
178
+
179
+ this.video.addEventListener('progress', () => {
180
+ this.updateBufferProgress();
181
+ });
182
+
183
+ this.video.addEventListener('waiting', () => {
184
+ this.setBuffering(true);
185
+ });
186
+
187
+ this.video.addEventListener('canplay', () => {
188
+ this.setBuffering(false);
189
+ this.emit('onReady');
190
+ });
191
+
192
+ this.video.addEventListener('loadedmetadata', () => {
193
+ this.state.duration = this.video!.duration;
194
+ this.emit('onLoadedMetadata', {
195
+ duration: this.video!.duration,
196
+ width: this.video!.videoWidth,
197
+ height: this.video!.videoHeight
198
+ });
199
+ });
200
+
201
+ this.video.addEventListener('volumechange', () => {
202
+ this.state.volume = this.video!.volume;
203
+ this.state.isMuted = this.video!.muted;
204
+ this.emit('onVolumeChanged', this.video!.volume);
205
+ });
206
+
207
+ this.video.addEventListener('error', (e) => {
208
+ const error = this.video!.error;
209
+ if (error) {
210
+ this.handleError({
211
+ code: `MEDIA_ERR_${error.code}`,
212
+ message: error.message || this.getMediaErrorMessage(error.code),
213
+ type: 'media',
214
+ fatal: true,
215
+ details: error
216
+ });
217
+ }
218
+ });
219
+
220
+ this.video.addEventListener('seeking', () => {
221
+ this.emit('onSeeking');
222
+ });
223
+
224
+ this.video.addEventListener('seeked', () => {
225
+ // Apply gate if user seeks beyond free preview
226
+ const t = this.video!.currentTime || 0;
227
+ this.enforceFreePreviewGate(t, true);
228
+ this.emit('onSeeked');
229
+ });
230
+ }
231
+
232
+ private getMediaErrorMessage(code: number): string {
233
+ switch (code) {
234
+ case 1: return 'Media loading aborted';
235
+ case 2: return 'Network error';
236
+ case 3: return 'Media decoding failed';
237
+ case 4: return 'Media format not supported';
238
+ default: return 'Unknown media error';
239
+ }
240
+ }
241
+
242
+ private updateBufferProgress(): void {
243
+ if (!this.video) return;
244
+
245
+ const buffered = this.video.buffered;
246
+ if (buffered.length > 0) {
247
+ const bufferedEnd = buffered.end(buffered.length - 1);
248
+ const duration = this.video.duration;
249
+ const percentage = duration > 0 ? (bufferedEnd / duration) * 100 : 0;
250
+ this.updateBuffered(percentage);
251
+ }
252
+ }
253
+
254
+ async load(source: VideoSource): Promise<void> {
255
+ this.source = source;
256
+ this.subtitles = source.subtitles || [];
257
+
258
+ // Clean up previous instances
259
+ await this.cleanup();
260
+
261
+ if (!this.video) {
262
+ throw new Error('Video element not initialized');
263
+ }
264
+
265
+
266
+ // Detect source type
267
+ const sourceType = this.detectSourceType(source);
268
+
269
+ try {
270
+ switch (sourceType) {
271
+ case 'hls':
272
+ await this.loadHLS(source.url);
273
+ break;
274
+ case 'dash':
275
+ await this.loadDASH(source.url);
276
+ break;
277
+ default:
278
+ await this.loadNative(source.url);
279
+ }
280
+
281
+ // Load subtitles if provided
282
+ if (source.subtitles && source.subtitles.length > 0) {
283
+ this.loadSubtitles(source.subtitles);
284
+ }
285
+
286
+ // Apply metadata
287
+ if (source.metadata) {
288
+ if (source.metadata.posterUrl && this.video) {
289
+ this.video.poster = source.metadata.posterUrl;
290
+ }
291
+ // Update player UI with metadata (title, description, thumbnail)
292
+ this.updateMetadataUI();
293
+ } else {
294
+ // Clear to defaults if no metadata
295
+ this.updateMetadataUI();
296
+ }
297
+
298
+ } catch (error) {
299
+ this.handleError({
300
+ code: 'LOAD_ERROR',
301
+ message: `Failed to load video: ${error}`,
302
+ type: 'network',
303
+ fatal: true,
304
+ details: error
305
+ });
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ private detectSourceType(source: VideoSource): string {
311
+ if (source.type && source.type !== 'auto') {
312
+ return source.type;
313
+ }
314
+
315
+ const url = source.url.toLowerCase();
316
+ if (url.includes('.m3u8')) return 'hls';
317
+ if (url.includes('.mpd')) return 'dash';
318
+ if (url.includes('.mp4')) return 'mp4';
319
+ if (url.includes('.webm')) return 'webm';
320
+
321
+ return 'mp4'; // default
322
+ }
323
+
324
+ private async loadHLS(url: string): Promise<void> {
325
+ // Check if HLS.js is available
326
+ if (!window.Hls) {
327
+ await this.loadScript('https://cdn.jsdelivr.net/npm/hls.js@latest');
328
+ }
329
+
330
+ if (window.Hls.isSupported()) {
331
+ this.hls = new window.Hls({
332
+ debug: this.config.debug,
333
+ enableWorker: true,
334
+ lowLatencyMode: false,
335
+ backBufferLength: 90
336
+ });
337
+
338
+ this.hls.loadSource(url);
339
+ this.hls.attachMedia(this.video);
340
+
341
+ this.hls.on(window.Hls.Events.MANIFEST_PARSED, (event: any, data: any) => {
342
+ // Extract quality levels
343
+ this.qualities = data.levels.map((level: any, index: number) => ({
344
+ height: level.height,
345
+ width: level.width || 0,
346
+ bitrate: level.bitrate,
347
+ label: `${level.height}p`,
348
+ index: index
349
+ }));
350
+
351
+ // Start playback if autoPlay is enabled
352
+ if (this.config.autoPlay) {
353
+ this.play();
354
+ }
355
+ });
356
+
357
+ this.hls.on(window.Hls.Events.LEVEL_SWITCHED, (event: any, data: any) => {
358
+ if (this.qualities[data.level]) {
359
+ this.currentQualityIndex = data.level;
360
+ this.state.currentQuality = this.qualities[data.level];
361
+ this.emit('onQualityChanged', this.qualities[data.level]);
362
+ }
363
+ });
364
+
365
+ this.hls.on(window.Hls.Events.ERROR, (event: any, data: any) => {
366
+ if (data.fatal) {
367
+ this.handleHLSError(data);
368
+ }
369
+ });
370
+ } else if (this.video!.canPlayType('application/vnd.apple.mpegurl')) {
371
+ // Native HLS support (Safari)
372
+ this.video!.src = url;
373
+ } else {
374
+ throw new Error('HLS is not supported in this browser');
375
+ }
376
+ }
377
+
378
+ private handleHLSError(data: any): void {
379
+ const Hls = window.Hls;
380
+ switch (data.type) {
381
+ case Hls.ErrorTypes.NETWORK_ERROR:
382
+ console.error('Fatal network error, trying to recover');
383
+ this.hls.startLoad();
384
+ break;
385
+ case Hls.ErrorTypes.MEDIA_ERROR:
386
+ console.error('Fatal media error, trying to recover');
387
+ this.hls.recoverMediaError();
388
+ break;
389
+ default:
390
+ console.error('Fatal error, cannot recover');
391
+ this.handleError({
392
+ code: 'HLS_ERROR',
393
+ message: data.details,
394
+ type: 'media',
395
+ fatal: true,
396
+ details: data
397
+ });
398
+ this.hls.destroy();
399
+ break;
400
+ }
401
+ }
402
+
403
+ private async loadDASH(url: string): Promise<void> {
404
+ // Check if dash.js is available
405
+ if (!window.dashjs) {
406
+ await this.loadScript('https://cdn.dashjs.org/latest/dash.all.min.js');
407
+ }
408
+
409
+ this.dash = window.dashjs.MediaPlayer().create();
410
+ this.dash.initialize(this.video, url, this.config.autoPlay);
411
+
412
+ // Configure DASH settings
413
+ this.dash.updateSettings({
414
+ streaming: {
415
+ abr: {
416
+ autoSwitchBitrate: {
417
+ video: this.config.enableAdaptiveBitrate ?? true,
418
+ audio: true
419
+ }
420
+ },
421
+ buffer: {
422
+ fastSwitchEnabled: true
423
+ }
424
+ }
425
+ });
426
+
427
+ // Listen for quality changes
428
+ this.dash.on(window.dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, (e: any) => {
429
+ if (e.mediaType === 'video') {
430
+ this.updateDASHQuality(e.newQuality);
431
+ }
432
+ });
433
+
434
+ // Extract available qualities
435
+ this.dash.on(window.dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => {
436
+ const bitrateList = this.dash.getBitrateInfoListFor('video');
437
+ if (bitrateList && bitrateList.length > 0) {
438
+ this.qualities = bitrateList.map((info: any, index: number) => ({
439
+ height: info.height || 0,
440
+ width: info.width || 0,
441
+ bitrate: info.bitrate,
442
+ label: `${info.height}p`,
443
+ index: index
444
+ }));
445
+ }
446
+ });
447
+
448
+ // Handle errors
449
+ this.dash.on(window.dashjs.MediaPlayer.events.ERROR, (e: any) => {
450
+ this.handleError({
451
+ code: 'DASH_ERROR',
452
+ message: e.error.message,
453
+ type: 'media',
454
+ fatal: true,
455
+ details: e
456
+ });
457
+ });
458
+ }
459
+
460
+ private updateDASHQuality(qualityIndex: number): void {
461
+ if (this.qualities[qualityIndex]) {
462
+ this.currentQualityIndex = qualityIndex;
463
+ this.state.currentQuality = this.qualities[qualityIndex];
464
+ this.emit('onQualityChanged', this.qualities[qualityIndex]);
465
+ }
466
+ }
467
+
468
+ private async loadNative(url: string): Promise<void> {
469
+ if (!this.video) return;
470
+ this.video.src = url;
471
+ this.video.load();
472
+ }
473
+
474
+ protected loadScript(src: string): Promise<void> {
475
+ return new Promise((resolve, reject) => {
476
+ const script = document.createElement('script');
477
+ script.src = src;
478
+ script.onload = () => resolve();
479
+ script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
480
+ document.head.appendChild(script);
481
+ });
482
+ }
483
+
484
+ private loadSubtitles(subtitles: SubtitleTrack[]): void {
485
+ if (!this.video) return;
486
+
487
+ // Remove existing tracks
488
+ const existingTracks = this.video.querySelectorAll('track');
489
+ existingTracks.forEach(track => track.remove());
490
+
491
+ // Add new subtitle tracks
492
+ subtitles.forEach((subtitle, index) => {
493
+ const track = document.createElement('track');
494
+ track.kind = subtitle.kind;
495
+ track.label = subtitle.label;
496
+ track.srclang = subtitle.language;
497
+ track.src = subtitle.url;
498
+
499
+ if (subtitle.default || index === 0) {
500
+ track.default = true;
501
+ }
502
+
503
+ this.video!.appendChild(track);
504
+ });
505
+ }
506
+
507
+ async play(): Promise<void> {
508
+ if (!this.video) throw new Error('Video element not initialized');
509
+
510
+
511
+ try {
512
+ await this.video.play();
513
+ await super.play();
514
+ } catch (error) {
515
+ this.handleError({
516
+ code: 'PLAY_ERROR',
517
+ message: `Failed to start playback: ${error}`,
518
+ type: 'media',
519
+ fatal: false,
520
+ details: error
521
+ });
522
+ throw error;
523
+ }
524
+ }
525
+
526
+ pause(): void {
527
+ if (!this.video) return;
528
+ this.video.pause();
529
+ super.pause();
530
+ }
531
+
532
+ seek(time: number): void {
533
+ if (!this.video) return;
534
+ const d = this.video.duration;
535
+ if (typeof d === 'number' && isFinite(d) && d > 0) {
536
+ this.video.currentTime = Math.max(0, Math.min(time, d));
537
+ } else {
538
+ this.video.currentTime = Math.max(0, time);
539
+ }
540
+ }
541
+
542
+ setVolume(level: number): void {
543
+ if (!this.video) return;
544
+ this.video.volume = Math.max(0, Math.min(1, level));
545
+ super.setVolume(level);
546
+ }
547
+
548
+ mute(): void {
549
+ if (!this.video) return;
550
+ this.video.muted = true;
551
+ super.mute();
552
+ }
553
+
554
+ unmute(): void {
555
+ if (!this.video) return;
556
+ this.video.muted = false;
557
+ super.unmute();
558
+ }
559
+
560
+ setPlaybackRate(rate: number): void {
561
+ if (!this.video) return;
562
+ this.video.playbackRate = rate;
563
+ super.setPlaybackRate(rate);
564
+ }
565
+
566
+ getCurrentTime(): number {
567
+ if (this.video && typeof this.video.currentTime === 'number') {
568
+ return this.video.currentTime;
569
+ }
570
+ return super.getCurrentTime();
571
+ }
572
+
573
+ getQualities(): Quality[] {
574
+ return this.qualities;
575
+ }
576
+
577
+ getCurrentQuality(): Quality | null {
578
+ return this.currentQualityIndex >= 0 ? this.qualities[this.currentQualityIndex] : null;
579
+ }
580
+
581
+ setQuality(index: number): void {
582
+ if (this.hls) {
583
+ this.hls.currentLevel = index;
584
+ } else if (this.dash) {
585
+ this.dash.setQualityFor('video', index);
586
+ }
587
+
588
+ this.currentQualityIndex = index;
589
+ this.autoQuality = false;
590
+ }
591
+
592
+ setAutoQuality(enabled: boolean): void {
593
+ this.autoQuality = enabled;
594
+
595
+ if (this.hls) {
596
+ this.hls.currentLevel = enabled ? -1 : this.currentQualityIndex;
597
+ } else if (this.dash) {
598
+ this.dash.updateSettings({
599
+ streaming: {
600
+ abr: {
601
+ autoSwitchBitrate: {
602
+ video: enabled
603
+ }
604
+ }
605
+ }
606
+ });
607
+ }
608
+ }
609
+
610
+ async enterFullscreen(): Promise<void> {
611
+ if (!this.video) return;
612
+
613
+ try {
614
+ if (this.video.requestFullscreen) {
615
+ await this.video.requestFullscreen();
616
+ } else if ((this.video as any).webkitRequestFullscreen) {
617
+ await (this.video as any).webkitRequestFullscreen();
618
+ } else if ((this.video as any).msRequestFullscreen) {
619
+ await (this.video as any).msRequestFullscreen();
620
+ }
621
+
622
+ this.emit('onFullscreenChanged', true);
623
+ } catch (error) {
624
+ console.error('Failed to enter fullscreen:', error);
625
+ throw error;
626
+ }
627
+ }
628
+
629
+ async exitFullscreen(): Promise<void> {
630
+ try {
631
+ if (document.exitFullscreen) {
632
+ await document.exitFullscreen();
633
+ } else if ((document as any).webkitExitFullscreen) {
634
+ await (document as any).webkitExitFullscreen();
635
+ } else if ((document as any).msExitFullscreen) {
636
+ await (document as any).msExitFullscreen();
637
+ }
638
+
639
+ this.emit('onFullscreenChanged', false);
640
+ } catch (error) {
641
+ console.error('Failed to exit fullscreen:', error);
642
+ throw error;
643
+ }
644
+ }
645
+
646
+ async enterPictureInPicture(): Promise<void> {
647
+ if (!this.video) return;
648
+
649
+ try {
650
+ if ((this.video as any).requestPictureInPicture) {
651
+ await (this.video as any).requestPictureInPicture();
652
+ } else {
653
+ throw new Error('Picture-in-Picture not supported');
654
+ }
655
+ } catch (error) {
656
+ console.error('Failed to enter PiP:', error);
657
+ throw error;
658
+ }
659
+ }
660
+
661
+ async exitPictureInPicture(): Promise<void> {
662
+ try {
663
+ if ((document as any).exitPictureInPicture) {
664
+ await (document as any).exitPictureInPicture();
665
+ }
666
+ } catch (error) {
667
+ console.error('Failed to exit PiP:', error);
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ protected applySubtitleTrack(track: SubtitleTrack): void {
673
+ if (!this.video) return;
674
+
675
+ const tracks = this.video.textTracks;
676
+ for (let i = 0; i < tracks.length; i++) {
677
+ const textTrack = tracks[i];
678
+ if (textTrack.label === track.label) {
679
+ textTrack.mode = 'showing';
680
+ } else {
681
+ textTrack.mode = 'hidden';
682
+ }
683
+ }
684
+ }
685
+
686
+ protected removeSubtitles(): void {
687
+ if (!this.video) return;
688
+
689
+ const tracks = this.video.textTracks;
690
+ for (let i = 0; i < tracks.length; i++) {
691
+ tracks[i].mode = 'hidden';
692
+ }
693
+ }
694
+
695
+ private injectStyles(): void {
696
+ if (document.getElementById('uvf-player-styles')) return;
697
+
698
+ const style = document.createElement('style');
699
+ style.id = 'uvf-player-styles';
700
+ style.textContent = this.getPlayerStyles();
701
+ document.head.appendChild(style);
702
+ }
703
+
704
+ private getPlayerStyles(): string {
705
+ return `
706
+ .uvf-player-wrapper {
707
+ position: relative;
708
+ width: 100%;
709
+ background: #000;
710
+ overflow: hidden;
711
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
712
+ /* Theme variables (can be overridden at runtime) */
713
+ --uvf-accent-1: #ff0000;
714
+ --uvf-accent-2: #ff4d4f;
715
+ --uvf-accent-1-20: rgba(255,0,0,0.2);
716
+ --uvf-icon-color: #ffffff;
717
+ --uvf-text-primary: #ffffff;
718
+ --uvf-text-secondary: rgba(255,255,255,0.75);
719
+ /* Scrollbar design variables */
720
+ --uvf-scrollbar-width: 8px;
721
+ --uvf-scrollbar-thumb-start: rgba(255,0,0,0.35);
722
+ --uvf-scrollbar-thumb-end: rgba(255,0,0,0.45);
723
+ --uvf-scrollbar-thumb-hover-start: rgba(255,0,0,0.5);
724
+ --uvf-scrollbar-thumb-hover-end: rgba(255,0,0,0.6);
725
+ --uvf-firefox-scrollbar-color: rgba(255,255,255,0.25);
726
+ }
727
+
728
+ /* Gradient border effect */
729
+ .uvf-player-wrapper::before {
730
+ content: '';
731
+ position: absolute;
732
+ top: -2px;
733
+ left: -2px;
734
+ right: -2px;
735
+ bottom: -2px;
736
+ background: linear-gradient(45deg, var(--uvf-accent-1), var(--uvf-accent-2), var(--uvf-accent-1));
737
+ background-size: 400% 400%;
738
+ animation: uvf-gradientBorder 10s ease infinite;
739
+ z-index: -1;
740
+ opacity: 0;
741
+ transition: opacity 0.3s ease;
742
+ }
743
+ .uvf-player-wrapper:hover::before {
744
+ opacity: 0.3;
745
+ }
746
+ @keyframes uvf-gradientBorder {
747
+ 0% { background-position: 0% 50%; }
748
+ 50% { background-position: 100% 50%; }
749
+ 100% { background-position: 0% 50%; }
750
+ }
751
+
752
+ .uvf-video-container {
753
+ position: relative;
754
+ width: 100%;
755
+ aspect-ratio: 16 / 9;
756
+ background: radial-gradient(ellipse at center, #1a1a2e 0%, #000 100%);
757
+ overflow: hidden;
758
+ }
759
+
760
+ .uvf-video {
761
+ position: absolute;
762
+ top: 0;
763
+ left: 0;
764
+ width: 100%;
765
+ height: 100%;
766
+ background: #000;
767
+ object-fit: contain;
768
+ }
769
+
770
+ .uvf-watermark-layer {
771
+ position: absolute;
772
+ top: 0;
773
+ left: 0;
774
+ width: 100%;
775
+ height: 100%;
776
+ pointer-events: none;
777
+ z-index: 5;
778
+ mix-blend-mode: screen;
779
+ }
780
+
781
+ /* Gradients */
782
+ .uvf-top-gradient, .uvf-controls-gradient {
783
+ position: absolute;
784
+ left: 0;
785
+ right: 0;
786
+ pointer-events: none;
787
+ opacity: 0;
788
+ transition: opacity 0.3s ease;
789
+ }
790
+
791
+ .uvf-top-gradient {
792
+ top: 0;
793
+ height: 120px;
794
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
795
+ z-index: 6;
796
+ }
797
+
798
+ .uvf-controls-gradient {
799
+ bottom: 0;
800
+ height: 150px;
801
+ background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
802
+ z-index: 9;
803
+ }
804
+
805
+ .uvf-player-wrapper:hover .uvf-top-gradient,
806
+ .uvf-player-wrapper:hover .uvf-controls-gradient,
807
+ .uvf-player-wrapper.controls-visible .uvf-top-gradient,
808
+ .uvf-player-wrapper.controls-visible .uvf-controls-gradient {
809
+ opacity: 1;
810
+ }
811
+
812
+ /* Loading Spinner */
813
+ .uvf-loading-container {
814
+ position: absolute;
815
+ top: 50%;
816
+ left: 50%;
817
+ transform: translate(-50%, -50%);
818
+ z-index: 10;
819
+ display: none;
820
+ }
821
+
822
+ .uvf-loading-container.active {
823
+ display: block;
824
+ }
825
+
826
+ .uvf-loading-spinner {
827
+ width: 60px;
828
+ height: 60px;
829
+ border: 3px solid rgba(255,255,255,0.2);
830
+ border-top-color: var(--uvf-accent-1);
831
+ border-radius: 50%;
832
+ animation: uvf-spin 1s linear infinite;
833
+ }
834
+
835
+ @keyframes uvf-spin {
836
+ to { transform: rotate(360deg); }
837
+ }
838
+
839
+ /* Center Play Button */
840
+ .uvf-center-play-btn {
841
+ position: absolute;
842
+ top: 50%;
843
+ left: 50%;
844
+ transform: translate(-50%, -50%);
845
+ width: 80px;
846
+ height: 80px;
847
+ background: rgba(255,255,255,0.1);
848
+ backdrop-filter: blur(10px);
849
+ border: 2px solid rgba(255,255,255,0.3);
850
+ border-radius: 50%;
851
+ display: flex;
852
+ align-items: center;
853
+ justify-content: center;
854
+ cursor: pointer;
855
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
856
+ z-index: 8;
857
+ }
858
+
859
+ .uvf-center-play-btn:hover {
860
+ transform: translate(-50%, -50%) scale(1.1);
861
+ background: rgba(255,255,255,0.2);
862
+ box-shadow: 0 0 40px rgba(255,255,255,0.4);
863
+ }
864
+
865
+ .uvf-center-play-btn.hidden {
866
+ opacity: 0;
867
+ transform: translate(-50%, -50%) scale(0.8);
868
+ pointer-events: none;
869
+ }
870
+
871
+ .uvf-center-play-btn svg {
872
+ width: 35px;
873
+ height: 35px;
874
+ fill: var(--uvf-icon-color);
875
+ margin-left: 4px;
876
+ }
877
+
878
+ /* Controls Bar */
879
+ .uvf-controls-bar {
880
+ position: absolute;
881
+ bottom: 0;
882
+ left: 0;
883
+ right: 0;
884
+ padding: 20px;
885
+ z-index: 10;
886
+ opacity: 0;
887
+ transform: translateY(10px);
888
+ transition: all 0.3s ease;
889
+ }
890
+
891
+ .uvf-player-wrapper:hover .uvf-controls-bar,
892
+ .uvf-player-wrapper.controls-visible .uvf-controls-bar {
893
+ opacity: 1;
894
+ transform: translateY(0);
895
+ }
896
+
897
+ .uvf-player-wrapper.no-cursor {
898
+ cursor: none;
899
+ }
900
+
901
+ .uvf-player-wrapper.no-cursor .uvf-controls-bar,
902
+ .uvf-player-wrapper.no-cursor .uvf-top-gradient,
903
+ .uvf-player-wrapper.no-cursor .uvf-controls-gradient {
904
+ opacity: 0 !important;
905
+ transform: translateY(10px) !important;
906
+ pointer-events: none;
907
+ }
908
+
909
+ /* Progress Bar */
910
+ .uvf-progress-section {
911
+ margin-bottom: 15px;
912
+ }
913
+
914
+ .uvf-progress-bar-wrapper {
915
+ position: relative;
916
+ width: 100%;
917
+ height: 6px;
918
+ background: rgba(255,255,255,0.1);
919
+ border-radius: 3px;
920
+ cursor: pointer;
921
+ overflow: visible;
922
+ transition: transform 0.2s ease;
923
+ }
924
+
925
+ .uvf-progress-bar-wrapper:hover {
926
+ transform: scaleY(1.5);
927
+ }
928
+
929
+ .uvf-progress-buffered {
930
+ position: absolute;
931
+ top: 0;
932
+ left: 0;
933
+ height: 100%;
934
+ background: rgba(255,255,255,0.2);
935
+ border-radius: 3px;
936
+ pointer-events: none;
937
+ }
938
+
939
+ .uvf-progress-filled {
940
+ position: absolute;
941
+ top: 0;
942
+ left: 0;
943
+ height: 100%;
944
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
945
+ border-radius: 3px;
946
+ pointer-events: none;
947
+ box-shadow: 0 0 10px var(--uvf-accent-1-20);
948
+ }
949
+
950
+ .uvf-progress-handle {
951
+ position: absolute;
952
+ top: 50%;
953
+ transform: translate(-50%, -50%) scale(0);
954
+ width: 16px;
955
+ height: 16px;
956
+ background: #fff;
957
+ border-radius: 50%;
958
+ box-shadow: 0 0 15px rgba(255,255,255,0.5);
959
+ transition: transform 0.2s ease;
960
+ pointer-events: none;
961
+ }
962
+
963
+ .uvf-progress-bar-wrapper:hover .uvf-progress-handle {
964
+ transform: translate(-50%, -50%) scale(1);
965
+ }
966
+
967
+ /* Controls Row */
968
+ .uvf-controls-row {
969
+ display: flex;
970
+ align-items: center;
971
+ gap: 15px;
972
+ }
973
+
974
+ /* Control Buttons */
975
+ .uvf-control-btn {
976
+ background: rgba(255,255,255,0.1);
977
+ backdrop-filter: blur(10px);
978
+ border: none;
979
+ width: 40px;
980
+ height: 40px;
981
+ border-radius: 50%;
982
+ color: #fff;
983
+ cursor: pointer;
984
+ display: flex;
985
+ align-items: center;
986
+ justify-content: center;
987
+ transition: all 0.3s ease;
988
+ position: relative;
989
+ overflow: hidden;
990
+ }
991
+
992
+ .uvf-control-btn:hover {
993
+ background: rgba(255,255,255,0.2);
994
+ transform: scale(1.1);
995
+ }
996
+
997
+ .uvf-control-btn:active {
998
+ transform: scale(0.95);
999
+ }
1000
+
1001
+ .uvf-control-btn svg {
1002
+ width: 20px;
1003
+ height: 20px;
1004
+ fill: var(--uvf-icon-color);
1005
+ pointer-events: none;
1006
+ }
1007
+
1008
+ .uvf-control-btn.play-pause {
1009
+ width: 50px;
1010
+ height: 50px;
1011
+ background: linear-gradient(135deg, var(--uvf-accent-1), var(--uvf-accent-2));
1012
+ }
1013
+
1014
+ .uvf-control-btn.play-pause svg {
1015
+ width: 24px;
1016
+ height: 24px;
1017
+ }
1018
+
1019
+ /* Time Display */
1020
+ .uvf-time-display {
1021
+ color: var(--uvf-text-primary);
1022
+ font-size: 14px;
1023
+ font-weight: 500;
1024
+ min-width: 120px;
1025
+ padding: 0 10px;
1026
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
1027
+ }
1028
+
1029
+ /* Volume Control */
1030
+ .uvf-volume-control {
1031
+ display: flex;
1032
+ align-items: center;
1033
+ position: relative;
1034
+ }
1035
+
1036
+ .uvf-volume-panel {
1037
+ position: absolute;
1038
+ left: 40px;
1039
+ top: 50%;
1040
+ transform: translateY(-50%);
1041
+ display: flex;
1042
+ align-items: center;
1043
+ background: rgba(0,0,0,0.95);
1044
+ backdrop-filter: blur(15px);
1045
+ border: 1px solid rgba(255,255,255,0.2);
1046
+ border-radius: 20px;
1047
+ padding: 10px 15px;
1048
+ opacity: 0;
1049
+ visibility: hidden;
1050
+ pointer-events: none;
1051
+ transition: opacity 0.2s ease, visibility 0.2s ease, left 0.3s ease;
1052
+ z-index: 100;
1053
+ }
1054
+
1055
+ .uvf-volume-control:hover .uvf-volume-panel,
1056
+ .uvf-volume-panel:hover,
1057
+ .uvf-volume-panel.active {
1058
+ opacity: 1;
1059
+ visibility: visible;
1060
+ pointer-events: all;
1061
+ left: 50px;
1062
+ }
1063
+
1064
+ .uvf-volume-slider {
1065
+ width: 120px;
1066
+ height: 8px;
1067
+ background: rgba(255,255,255,0.2);
1068
+ border-radius: 4px;
1069
+ cursor: pointer;
1070
+ position: relative;
1071
+ margin: 0 10px;
1072
+ }
1073
+
1074
+ .uvf-volume-fill {
1075
+ height: 100%;
1076
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
1077
+ border-radius: 4px;
1078
+ pointer-events: none;
1079
+ transition: width 0.1s ease;
1080
+ position: absolute;
1081
+ top: 0;
1082
+ left: 0;
1083
+ }
1084
+
1085
+ .uvf-volume-value {
1086
+ color: var(--uvf-text-primary);
1087
+ font-size: 12px;
1088
+ min-width: 30px;
1089
+ text-align: center;
1090
+ }
1091
+
1092
+ /* Right Controls */
1093
+ .uvf-right-controls {
1094
+ margin-left: auto;
1095
+ display: flex;
1096
+ align-items: center;
1097
+ gap: 10px;
1098
+ }
1099
+
1100
+ /* Settings Menu */
1101
+ .uvf-settings-menu {
1102
+ position: absolute;
1103
+ bottom: 50px;
1104
+ right: 0;
1105
+ background: rgba(0,0,0,0.95);
1106
+ backdrop-filter: blur(20px);
1107
+ border: 1px solid rgba(255,255,255,0.1);
1108
+ border-radius: 12px;
1109
+ padding: 10px 0;
1110
+ min-width: 200px;
1111
+ max-height: 60vh;
1112
+ overflow-y: auto;
1113
+ -webkit-overflow-scrolling: touch;
1114
+ overscroll-behavior: contain;
1115
+ /* Firefox */
1116
+ scrollbar-width: thin;
1117
+ scrollbar-color: var(--uvf-firefox-scrollbar-color) transparent;
1118
+ /* Avoid layout shift when scrollbar appears */
1119
+ scrollbar-gutter: stable both-edges;
1120
+ /* Space on the right so content doesn't hug the scrollbar */
1121
+ padding-right: 6px;
1122
+ opacity: 0;
1123
+ visibility: hidden;
1124
+ transform: translateY(10px);
1125
+ transition: all 0.3s ease;
1126
+ }
1127
+
1128
+ /* WebKit-based browsers (Chrome, Edge, Safari) */
1129
+ .uvf-settings-menu::-webkit-scrollbar {
1130
+ width: var(--uvf-scrollbar-width);
1131
+ }
1132
+ .uvf-settings-menu::-webkit-scrollbar-track {
1133
+ background: transparent;
1134
+ }
1135
+ .uvf-settings-menu::-webkit-scrollbar-thumb {
1136
+ background: linear-gradient(180deg, var(--uvf-scrollbar-thumb-start), var(--uvf-scrollbar-thumb-end));
1137
+ border-radius: 8px;
1138
+ }
1139
+ .uvf-settings-menu::-webkit-scrollbar-thumb:hover {
1140
+ background: linear-gradient(180deg, var(--uvf-scrollbar-thumb-hover-start), var(--uvf-scrollbar-thumb-hover-end));
1141
+ }
1142
+ .uvf-settings-menu::-webkit-scrollbar-corner {
1143
+ background: transparent;
1144
+ }
1145
+
1146
+ /* Scrollbar mode variants */
1147
+ .uvf-player-wrapper.uvf-scrollbar-compact {
1148
+ --uvf-scrollbar-width: 6px;
1149
+ --uvf-scrollbar-thumb-start: rgba(255,0,0,0.30);
1150
+ --uvf-scrollbar-thumb-end: rgba(255,0,0,0.38);
1151
+ --uvf-scrollbar-thumb-hover-start: rgba(255,0,0,0.42);
1152
+ --uvf-scrollbar-thumb-hover-end: rgba(255,0,0,0.52);
1153
+ --uvf-firefox-scrollbar-color: rgba(255,255,255,0.20);
1154
+ }
1155
+ .uvf-player-wrapper.uvf-scrollbar-overlay {
1156
+ --uvf-scrollbar-width: 6px;
1157
+ }
1158
+ .uvf-player-wrapper.uvf-scrollbar-overlay .uvf-settings-menu {
1159
+ scrollbar-gutter: auto;
1160
+ padding-right: 0;
1161
+ }
1162
+
1163
+ .uvf-settings-menu.active {
1164
+ opacity: 1;
1165
+ visibility: visible;
1166
+ transform: translateY(0);
1167
+ }
1168
+
1169
+ .uvf-settings-group {
1170
+ padding: 10px 0;
1171
+ border-bottom: 1px solid rgba(255,255,255,0.1);
1172
+ }
1173
+
1174
+ .uvf-settings-group:last-child {
1175
+ border-bottom: none;
1176
+ }
1177
+
1178
+ .uvf-settings-label {
1179
+ color: rgba(255,255,255,0.5);
1180
+ font-size: 11px;
1181
+ text-transform: uppercase;
1182
+ letter-spacing: 1px;
1183
+ padding: 0 15px 5px;
1184
+ }
1185
+
1186
+ .uvf-settings-option {
1187
+ color: #fff;
1188
+ font-size: 14px;
1189
+ padding: 8px 15px;
1190
+ cursor: pointer;
1191
+ transition: all 0.2s ease;
1192
+ display: flex;
1193
+ justify-content: space-between;
1194
+ align-items: center;
1195
+ }
1196
+
1197
+ .uvf-settings-option:hover {
1198
+ background: rgba(255,255,255,0.1);
1199
+ padding-left: 20px;
1200
+ }
1201
+
1202
+ .uvf-settings-option.active {
1203
+ color: var(--uvf-accent-2);
1204
+ }
1205
+
1206
+ .uvf-settings-option.active::after {
1207
+ content: '✓';
1208
+ margin-left: 10px;
1209
+ }
1210
+
1211
+ /* Title Bar */
1212
+ .uvf-title-bar {
1213
+ position: absolute;
1214
+ top: 0;
1215
+ left: 0;
1216
+ right: 0;
1217
+ padding: 20px;
1218
+ z-index: 7;
1219
+ opacity: 0;
1220
+ transform: translateY(-10px);
1221
+ transition: all 0.3s ease;
1222
+ }
1223
+
1224
+ .uvf-player-wrapper:hover .uvf-title-bar,
1225
+ .uvf-player-wrapper.controls-visible .uvf-title-bar {
1226
+ opacity: 1;
1227
+ transform: translateY(0);
1228
+ }
1229
+
1230
+ .uvf-title-content {
1231
+ display: flex;
1232
+ align-items: center;
1233
+ gap: 12px;
1234
+ }
1235
+ .uvf-video-thumb {
1236
+ width: 56px;
1237
+ height: 56px;
1238
+ border-radius: 8px;
1239
+ object-fit: cover;
1240
+ box-shadow: 0 4px 14px rgba(0,0,0,0.5);
1241
+ border: 1px solid rgba(255,255,255,0.25);
1242
+ background: rgba(255,255,255,0.05);
1243
+ }
1244
+ .uvf-title-text { display: flex; flex-direction: column; }
1245
+ .uvf-video-title {
1246
+ color: var(--uvf-text-primary);
1247
+ font-size: 18px;
1248
+ font-weight: 600;
1249
+ text-shadow: 0 2px 4px rgba(0,0,0,0.5);
1250
+ }
1251
+
1252
+ .uvf-video-subtitle {
1253
+ color: var(--uvf-text-secondary);
1254
+ font-size: 13px;
1255
+ margin-top: 4px;
1256
+ max-width: min(70vw, 900px);
1257
+ overflow: hidden;
1258
+ text-overflow: ellipsis;
1259
+ white-space: nowrap;
1260
+ }
1261
+
1262
+ /* Top Controls */
1263
+ .uvf-top-controls {
1264
+ position: absolute;
1265
+ top: 20px;
1266
+ right: 20px;
1267
+ z-index: 10;
1268
+ display: flex;
1269
+ gap: 10px;
1270
+ opacity: 0;
1271
+ transform: translateY(-10px);
1272
+ transition: all 0.3s ease;
1273
+ }
1274
+
1275
+ .uvf-player-wrapper:hover .uvf-top-controls,
1276
+ .uvf-player-wrapper.controls-visible .uvf-top-controls,
1277
+ .uvf-player-wrapper.uvf-casting .uvf-top-controls {
1278
+ opacity: 1;
1279
+ transform: translateY(0);
1280
+ }
1281
+
1282
+ .uvf-top-btn {
1283
+ width: 40px;
1284
+ height: 40px;
1285
+ background: rgba(255,255,255,0.1);
1286
+ backdrop-filter: blur(10px);
1287
+ border: 1px solid rgba(255,255,255,0.2);
1288
+ border-radius: 50%;
1289
+ cursor: pointer;
1290
+ display: flex;
1291
+ align-items: center;
1292
+ justify-content: center;
1293
+ transition: all 0.3s ease;
1294
+ }
1295
+
1296
+ .uvf-top-btn:hover {
1297
+ background: rgba(255,255,255,0.2);
1298
+ transform: scale(1.1);
1299
+ box-shadow: 0 0 20px rgba(255,255,255,0.3);
1300
+ }
1301
+
1302
+ .uvf-top-btn.cast-grey {
1303
+ opacity: 0.6;
1304
+ filter: grayscale(0.6);
1305
+ box-shadow: none;
1306
+ background: rgba(255,255,255,0.08);
1307
+ }
1308
+ .uvf-top-btn.cast-grey:hover {
1309
+ transform: none;
1310
+ background: rgba(255,255,255,0.12);
1311
+ box-shadow: none;
1312
+ }
1313
+
1314
+ .uvf-top-btn svg {
1315
+ width: 20px;
1316
+ height: 20px;
1317
+ fill: var(--uvf-icon-color);
1318
+ }
1319
+
1320
+ /* Pill-style button for prominent actions */
1321
+ .uvf-pill-btn {
1322
+ display: inline-flex;
1323
+ align-items: center;
1324
+ gap: 8px;
1325
+ height: 40px;
1326
+ padding: 0 14px;
1327
+ border-radius: 999px;
1328
+ border: 1px solid rgba(255,255,255,0.25);
1329
+ background: rgba(255,255,255,0.08);
1330
+ color: #fff;
1331
+ cursor: pointer;
1332
+ transition: all 0.2s ease;
1333
+ box-shadow: 0 4px 14px rgba(0,0,0,0.4);
1334
+ }
1335
+ .uvf-pill-btn:hover {
1336
+ transform: translateY(-1px);
1337
+ background: rgba(255,255,255,0.15);
1338
+ box-shadow: 0 6px 18px rgba(0,0,0,0.5);
1339
+ }
1340
+ .uvf-pill-btn:active {
1341
+ transform: translateY(0);
1342
+ }
1343
+ .uvf-pill-btn svg {
1344
+ width: 18px;
1345
+ height: 18px;
1346
+ fill: currentColor;
1347
+ }
1348
+ .uvf-stop-cast-btn {
1349
+ background: linear-gradient(135deg, #ff4d4f, #d9363e);
1350
+ border: 1px solid rgba(255, 77, 79, 0.6);
1351
+ box-shadow: 0 0 20px rgba(255, 77, 79, 0.35);
1352
+ }
1353
+ .uvf-stop-cast-btn:hover {
1354
+ background: linear-gradient(135deg, #ff6b6d, #f0444b);
1355
+ box-shadow: 0 0 26px rgba(255, 77, 79, 0.5);
1356
+ }
1357
+
1358
+ /* Quality Badge */
1359
+ .uvf-quality-badge {
1360
+ background: var(--uvf-accent-1-20);
1361
+ border: 1px solid var(--uvf-accent-1);
1362
+ color: var(--uvf-accent-1);
1363
+ font-size: 11px;
1364
+ font-weight: 600;
1365
+ padding: 4px 8px;
1366
+ border-radius: 4px;
1367
+ text-transform: uppercase;
1368
+ }
1369
+
1370
+ /* Time Tooltip */
1371
+ .uvf-time-tooltip {
1372
+ position: absolute;
1373
+ bottom: 20px;
1374
+ background: rgba(0,0,0,0.9);
1375
+ color: #fff;
1376
+ padding: 5px 10px;
1377
+ border-radius: 4px;
1378
+ font-size: 12px;
1379
+ pointer-events: none;
1380
+ opacity: 0;
1381
+ transform: translateX(-50%);
1382
+ transition: opacity 0.2s ease;
1383
+ }
1384
+
1385
+ .uvf-progress-bar-wrapper:hover .uvf-time-tooltip {
1386
+ opacity: 1;
1387
+ }
1388
+
1389
+ /* Shortcut Indicator */
1390
+ .uvf-shortcut-indicator {
1391
+ position: absolute;
1392
+ top: 50%;
1393
+ left: 50%;
1394
+ transform: translate(-50%, -50%);
1395
+ background: rgba(0,0,0,0.8);
1396
+ color: #fff;
1397
+ padding: 20px 30px;
1398
+ border-radius: 8px;
1399
+ font-size: 24px;
1400
+ font-weight: 600;
1401
+ opacity: 0;
1402
+ pointer-events: none;
1403
+ z-index: 20;
1404
+ transition: opacity 0.3s ease;
1405
+ }
1406
+
1407
+ .uvf-shortcut-indicator.active {
1408
+ animation: uvf-fadeInOut 1s ease;
1409
+ }
1410
+
1411
+ /* Key action overlay styles (YouTube-like) */
1412
+ .uvf-shortcut-indicator.uvf-ki-icon {
1413
+ background: transparent;
1414
+ padding: 0;
1415
+ border-radius: 50%;
1416
+ }
1417
+ .uvf-shortcut-indicator .uvf-ki {
1418
+ display: inline-flex;
1419
+ align-items: center;
1420
+ justify-content: center;
1421
+ gap: 10px;
1422
+ color: var(--uvf-icon-color);
1423
+ }
1424
+ .uvf-shortcut-indicator .uvf-ki svg {
1425
+ width: 72px;
1426
+ height: 72px;
1427
+ fill: var(--uvf-icon-color);
1428
+ filter: drop-shadow(0 2px 6px rgba(0,0,0,0.45));
1429
+ }
1430
+ .uvf-shortcut-indicator .uvf-ki-skip {
1431
+ position: relative;
1432
+ width: 110px;
1433
+ height: 110px;
1434
+ }
1435
+ .uvf-shortcut-indicator .uvf-ki-skip svg {
1436
+ width: 110px;
1437
+ height: 110px;
1438
+ position: relative;
1439
+ z-index: 1;
1440
+ }
1441
+ .uvf-shortcut-indicator .uvf-ki-skip .uvf-ki-skip-num {
1442
+ position: absolute;
1443
+ top: 52%;
1444
+ left: 50%;
1445
+ transform: translate(-50%, -50%);
1446
+ color: var(--uvf-text-primary);
1447
+ font-weight: 800;
1448
+ font-size: 22px;
1449
+ text-shadow: 0 2px 6px rgba(0,0,0,0.5);
1450
+ pointer-events: none;
1451
+ z-index: 2;
1452
+ }
1453
+ .uvf-shortcut-indicator .uvf-ki-volume { align-items: center; }
1454
+ .uvf-shortcut-indicator .uvf-ki-vol-icon svg { width: 36px; height: 36px; }
1455
+ .uvf-shortcut-indicator .uvf-ki-vol-bar {
1456
+ width: 180px;
1457
+ height: 8px;
1458
+ background: rgba(255,255,255,0.25);
1459
+ border-radius: 4px;
1460
+ overflow: hidden;
1461
+ }
1462
+ .uvf-shortcut-indicator .uvf-ki-vol-fill {
1463
+ height: 100%;
1464
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
1465
+ }
1466
+ .uvf-shortcut-indicator .uvf-ki-vol-text {
1467
+ font-size: 16px;
1468
+ font-weight: 600;
1469
+ color: var(--uvf-text-primary);
1470
+ min-width: 42px;
1471
+ text-align: right;
1472
+ }
1473
+ .uvf-shortcut-indicator .uvf-ki-text {
1474
+ font-size: 18px;
1475
+ color: var(--uvf-text-primary);
1476
+ }
1477
+
1478
+ @keyframes uvf-fadeInOut {
1479
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
1480
+ 20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
1481
+ 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
1482
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
1483
+ }
1484
+
1485
+ /* Hide top elements with controls */
1486
+ .uvf-player-wrapper.no-cursor .uvf-title-bar,
1487
+ .uvf-player-wrapper.no-cursor .uvf-top-controls {
1488
+ opacity: 0 !important;
1489
+ transform: translateY(-10px) !important;
1490
+ pointer-events: none;
1491
+ }
1492
+ `;
1493
+ }
1494
+
1495
+ private createCustomControls(container: HTMLElement): void {
1496
+ // Add gradients
1497
+ const topGradient = document.createElement('div');
1498
+ topGradient.className = 'uvf-top-gradient';
1499
+ container.appendChild(topGradient);
1500
+
1501
+ const controlsGradient = document.createElement('div');
1502
+ controlsGradient.className = 'uvf-controls-gradient';
1503
+ container.appendChild(controlsGradient);
1504
+
1505
+ // Add title bar
1506
+ const titleBar = document.createElement('div');
1507
+ titleBar.className = 'uvf-title-bar';
1508
+ titleBar.innerHTML = `
1509
+ <div class="uvf-title-content">
1510
+ <img class="uvf-video-thumb" id="uvf-video-thumb" alt="thumbnail" style="display:none;" />
1511
+ <div class="uvf-title-text">
1512
+ <div class=\"uvf-video-title\" id=\"uvf-video-title\" style=\"display:none;\"></div>
1513
+ <div class=\"uvf-video-subtitle\" id=\"uvf-video-description\" style=\"display:none;\"></div>
1514
+ </div>
1515
+ </div>
1516
+ `;
1517
+ container.appendChild(titleBar);
1518
+
1519
+ // Add top controls
1520
+ const topControls = document.createElement('div');
1521
+ topControls.className = 'uvf-top-controls';
1522
+ topControls.innerHTML = `
1523
+ <div class="uvf-top-btn" id="uvf-cast-btn" title="Cast" aria-label="Cast">
1524
+ <svg viewBox="0 0 24 24">
1525
+ <path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
1526
+ </svg>
1527
+ </div>
1528
+ <button class="uvf-pill-btn uvf-stop-cast-btn" id="uvf-stop-cast-btn" title="Stop Casting" aria-label="Stop Casting" style="display: none;">
1529
+ <svg viewBox="0 0 24 24" aria-hidden="true">
1530
+ <path d="M6 6h12v12H6z"/>
1531
+ </svg>
1532
+ <span>Stop Casting</span>
1533
+ </button>
1534
+ <div class="uvf-top-btn" id="uvf-playlist-btn" title="Add to Playlist">
1535
+ <svg viewBox="0 0 24 24">
1536
+ <path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
1537
+ </svg>
1538
+ </div>
1539
+ <div class="uvf-top-btn" id="uvf-share-btn" title="Share">
1540
+ <svg viewBox="0 0 24 24">
1541
+ <path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
1542
+ </svg>
1543
+ </div>
1544
+ `;
1545
+ container.appendChild(topControls);
1546
+
1547
+ // Add loading spinner
1548
+ const loadingContainer = document.createElement('div');
1549
+ loadingContainer.className = 'uvf-loading-container';
1550
+ loadingContainer.id = 'uvf-loading';
1551
+ loadingContainer.innerHTML = '<div class="uvf-loading-spinner"></div>';
1552
+ container.appendChild(loadingContainer);
1553
+
1554
+ // Add center play button
1555
+ const centerPlayBtn = document.createElement('div');
1556
+ centerPlayBtn.className = 'uvf-center-play-btn';
1557
+ centerPlayBtn.id = 'uvf-center-play';
1558
+ centerPlayBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
1559
+ container.appendChild(centerPlayBtn);
1560
+
1561
+ // Add shortcut indicator
1562
+ const shortcutIndicator = document.createElement('div');
1563
+ shortcutIndicator.className = 'uvf-shortcut-indicator';
1564
+ shortcutIndicator.id = 'uvf-shortcut-indicator';
1565
+ container.appendChild(shortcutIndicator);
1566
+
1567
+ // Create controls bar
1568
+ const controlsBar = document.createElement('div');
1569
+ controlsBar.className = 'uvf-controls-bar';
1570
+ controlsBar.id = 'uvf-controls';
1571
+
1572
+ // Progress section
1573
+ const progressSection = document.createElement('div');
1574
+ progressSection.className = 'uvf-progress-section';
1575
+
1576
+ const progressBar = document.createElement('div');
1577
+ progressBar.className = 'uvf-progress-bar-wrapper';
1578
+ progressBar.id = 'uvf-progress-bar';
1579
+ progressBar.innerHTML = `
1580
+ <div class="uvf-progress-buffered" id="uvf-progress-buffered"></div>
1581
+ <div class="uvf-progress-filled" id="uvf-progress-filled"></div>
1582
+ <div class="uvf-progress-handle" id="uvf-progress-handle"></div>
1583
+ <div class="uvf-time-tooltip" id="uvf-time-tooltip">00:00</div>
1584
+ `;
1585
+ progressSection.appendChild(progressBar);
1586
+
1587
+ // Controls row
1588
+ const controlsRow = document.createElement('div');
1589
+ controlsRow.className = 'uvf-controls-row';
1590
+
1591
+ // Play/Pause button
1592
+ const playPauseBtn = document.createElement('button');
1593
+ playPauseBtn.className = 'uvf-control-btn play-pause';
1594
+ playPauseBtn.id = 'uvf-play-pause';
1595
+ playPauseBtn.innerHTML = `
1596
+ <svg viewBox="0 0 24 24" id="uvf-play-icon">
1597
+ <path d="M8 5v14l11-7z"/>
1598
+ </svg>
1599
+ <svg viewBox="0 0 24 24" id="uvf-pause-icon" style="display: none;">
1600
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1601
+ </svg>
1602
+ `;
1603
+ controlsRow.appendChild(playPauseBtn);
1604
+
1605
+ // Skip buttons
1606
+ const skipBackBtn = document.createElement('button');
1607
+ skipBackBtn.className = 'uvf-control-btn';
1608
+ skipBackBtn.id = 'uvf-skip-back';
1609
+ skipBackBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>';
1610
+ controlsRow.appendChild(skipBackBtn);
1611
+
1612
+ const skipForwardBtn = document.createElement('button');
1613
+ skipForwardBtn.className = 'uvf-control-btn';
1614
+ skipForwardBtn.id = 'uvf-skip-forward';
1615
+ skipForwardBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12.01 19c-3.31 0-6-2.69-6-6s2.69-6 6-6V5l5 5-5 5V9c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4h2c0 3.31-2.69 6-6 6z"/></svg>';
1616
+ controlsRow.appendChild(skipForwardBtn);
1617
+
1618
+ // Volume control
1619
+ const volumeControl = document.createElement('div');
1620
+ volumeControl.className = 'uvf-volume-control';
1621
+ volumeControl.innerHTML = `
1622
+ <button class="uvf-control-btn" id="uvf-volume-btn">
1623
+ <svg viewBox="0 0 24 24" id="uvf-volume-icon">
1624
+ <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
1625
+ </svg>
1626
+ <svg viewBox="0 0 24 24" id="uvf-mute-icon" style="display: none;">
1627
+ <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
1628
+ </svg>
1629
+ </button>
1630
+ <div class="uvf-volume-panel" id="uvf-volume-panel">
1631
+ <div class="uvf-volume-slider" id="uvf-volume-slider">
1632
+ <div class="uvf-volume-fill" id="uvf-volume-fill" style="width: 100%;"></div>
1633
+ </div>
1634
+ <div class="uvf-volume-value" id="uvf-volume-value">100</div>
1635
+ </div>
1636
+ `;
1637
+ controlsRow.appendChild(volumeControl);
1638
+
1639
+ // Time display
1640
+ const timeDisplay = document.createElement('div');
1641
+ timeDisplay.className = 'uvf-time-display';
1642
+ timeDisplay.id = 'uvf-time-display';
1643
+ timeDisplay.textContent = '00:00 / 00:00';
1644
+ controlsRow.appendChild(timeDisplay);
1645
+
1646
+ // Right controls
1647
+ const rightControls = document.createElement('div');
1648
+ rightControls.className = 'uvf-right-controls';
1649
+
1650
+ // Quality badge
1651
+ const qualityBadge = document.createElement('div');
1652
+ qualityBadge.className = 'uvf-quality-badge';
1653
+ qualityBadge.id = 'uvf-quality-badge';
1654
+ qualityBadge.textContent = 'HD';
1655
+ rightControls.appendChild(qualityBadge);
1656
+
1657
+ // Settings button with menu
1658
+ const settingsContainer = document.createElement('div');
1659
+ settingsContainer.style.position = 'relative';
1660
+
1661
+ const settingsBtn = document.createElement('button');
1662
+ settingsBtn.className = 'uvf-control-btn';
1663
+ settingsBtn.id = 'uvf-settings-btn';
1664
+ settingsBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>';
1665
+ settingsContainer.appendChild(settingsBtn);
1666
+
1667
+ // Settings menu
1668
+ const settingsMenu = document.createElement('div');
1669
+ settingsMenu.className = 'uvf-settings-menu';
1670
+ settingsMenu.id = 'uvf-settings-menu';
1671
+ settingsMenu.innerHTML = `
1672
+ <div class="uvf-settings-group">
1673
+ <div class="uvf-settings-label">Playback Speed</div>
1674
+ <div class="uvf-settings-option speed-option" data-speed="0.5">0.5x</div>
1675
+ <div class="uvf-settings-option speed-option" data-speed="0.75">0.75x</div>
1676
+ <div class="uvf-settings-option speed-option active" data-speed="1">Normal</div>
1677
+ <div class="uvf-settings-option speed-option" data-speed="1.25">1.25x</div>
1678
+ <div class="uvf-settings-option speed-option" data-speed="1.5">1.5x</div>
1679
+ <div class="uvf-settings-option speed-option" data-speed="2">2x</div>
1680
+ </div>
1681
+ <div class="uvf-settings-group">
1682
+ <div class="uvf-settings-label">Quality</div>
1683
+ <div class="uvf-settings-option quality-option active" data-quality="auto">Auto</div>
1684
+ <div class="uvf-settings-option quality-option" data-quality="1080">1080p</div>
1685
+ <div class="uvf-settings-option quality-option" data-quality="720">720p</div>
1686
+ <div class="uvf-settings-option quality-option" data-quality="480">480p</div>
1687
+ </div>
1688
+ `;
1689
+ settingsContainer.appendChild(settingsMenu);
1690
+ rightControls.appendChild(settingsContainer);
1691
+
1692
+ // PiP button
1693
+ const pipBtn = document.createElement('button');
1694
+ pipBtn.className = 'uvf-control-btn';
1695
+ pipBtn.id = 'uvf-pip-btn';
1696
+ pipBtn.title = 'Picture-in-Picture';
1697
+ pipBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>';
1698
+ rightControls.appendChild(pipBtn);
1699
+
1700
+ // Fullscreen button
1701
+ const fullscreenBtn = document.createElement('button');
1702
+ fullscreenBtn.className = 'uvf-control-btn';
1703
+ fullscreenBtn.id = 'uvf-fullscreen-btn';
1704
+ fullscreenBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>';
1705
+ rightControls.appendChild(fullscreenBtn);
1706
+
1707
+ controlsRow.appendChild(rightControls);
1708
+
1709
+ // Assemble controls bar
1710
+ controlsBar.appendChild(progressSection);
1711
+ controlsBar.appendChild(controlsRow);
1712
+ container.appendChild(controlsBar);
1713
+
1714
+ this.controlsContainer = controlsBar;
1715
+ }
1716
+
1717
+ private setupControlsEventListeners(): void {
1718
+ if (!this.useCustomControls || !this.video) return;
1719
+
1720
+ const wrapper = this.container?.querySelector('.uvf-player-wrapper') as HTMLElement;
1721
+ const centerPlay = document.getElementById('uvf-center-play');
1722
+ const playPauseBtn = document.getElementById('uvf-play-pause');
1723
+ const skipBackBtn = document.getElementById('uvf-skip-back');
1724
+ const skipForwardBtn = document.getElementById('uvf-skip-forward');
1725
+ const volumeBtn = document.getElementById('uvf-volume-btn');
1726
+ const volumePanel = document.getElementById('uvf-volume-panel');
1727
+ const volumeSlider = document.getElementById('uvf-volume-slider');
1728
+ const progressBar = document.getElementById('uvf-progress-bar');
1729
+ const fullscreenBtn = document.getElementById('uvf-fullscreen-btn');
1730
+ const settingsBtn = document.getElementById('uvf-settings-btn');
1731
+
1732
+ // Disable right-click context menu
1733
+ this.video.addEventListener('contextmenu', (e) => {
1734
+ e.preventDefault();
1735
+ return false;
1736
+ });
1737
+
1738
+ wrapper?.addEventListener('contextmenu', (e) => {
1739
+ e.preventDefault();
1740
+ return false;
1741
+ });
1742
+
1743
+ // Play/Pause
1744
+ centerPlay?.addEventListener('click', () => this.togglePlayPause());
1745
+ playPauseBtn?.addEventListener('click', () => this.togglePlayPause());
1746
+ this.video.addEventListener('click', () => this.togglePlayPause());
1747
+
1748
+ // Update play/pause icons
1749
+ this.video.addEventListener('play', () => {
1750
+ const playIcon = document.getElementById('uvf-play-icon');
1751
+ const pauseIcon = document.getElementById('uvf-pause-icon');
1752
+ if (playIcon) playIcon.style.display = 'none';
1753
+ if (pauseIcon) pauseIcon.style.display = 'block';
1754
+ if (centerPlay) centerPlay.classList.add('hidden');
1755
+
1756
+ // Schedule hide controls
1757
+ setTimeout(() => {
1758
+ if (this.state.isPlaying) {
1759
+ this.scheduleHideControls();
1760
+ }
1761
+ }, 1000);
1762
+ });
1763
+
1764
+ this.video.addEventListener('pause', () => {
1765
+ const playIcon = document.getElementById('uvf-play-icon');
1766
+ const pauseIcon = document.getElementById('uvf-pause-icon');
1767
+ if (playIcon) playIcon.style.display = 'block';
1768
+ if (pauseIcon) pauseIcon.style.display = 'none';
1769
+ if (centerPlay) centerPlay.classList.remove('hidden');
1770
+ this.showControls();
1771
+ });
1772
+
1773
+ // Skip buttons
1774
+ skipBackBtn?.addEventListener('click', () => this.seek(this.video!.currentTime - 10));
1775
+ skipForwardBtn?.addEventListener('click', () => this.seek(this.video!.currentTime + 10));
1776
+
1777
+ // Volume control
1778
+ volumeBtn?.addEventListener('click', (e) => {
1779
+ e.stopPropagation();
1780
+ this.toggleMuteAction();
1781
+ });
1782
+
1783
+ // Volume panel interactions
1784
+ volumeBtn?.addEventListener('mouseenter', () => {
1785
+ clearTimeout(this.volumeHideTimeout);
1786
+ volumePanel?.classList.add('active');
1787
+ });
1788
+
1789
+ volumeBtn?.addEventListener('mouseleave', () => {
1790
+ this.volumeHideTimeout = setTimeout(() => {
1791
+ if (!volumePanel?.matches(':hover')) {
1792
+ volumePanel?.classList.remove('active');
1793
+ }
1794
+ }, 800);
1795
+ });
1796
+
1797
+ volumePanel?.addEventListener('mouseenter', () => {
1798
+ clearTimeout(this.volumeHideTimeout);
1799
+ volumePanel.classList.add('active');
1800
+ });
1801
+
1802
+ volumePanel?.addEventListener('mouseleave', () => {
1803
+ if (!this.isVolumeSliding) {
1804
+ setTimeout(() => {
1805
+ if (!volumePanel.matches(':hover') && !volumeBtn?.matches(':hover')) {
1806
+ volumePanel.classList.remove('active');
1807
+ }
1808
+ }, 1500);
1809
+ }
1810
+ });
1811
+
1812
+ // Volume slider
1813
+ volumeSlider?.addEventListener('mousedown', (e) => {
1814
+ e.stopPropagation();
1815
+ this.isVolumeSliding = true;
1816
+ volumePanel?.classList.add('active');
1817
+ this.handleVolumeChange(e as MouseEvent);
1818
+ });
1819
+
1820
+ volumeSlider?.addEventListener('click', (e) => {
1821
+ e.stopPropagation();
1822
+ this.handleVolumeChange(e as MouseEvent);
1823
+ });
1824
+
1825
+ document.addEventListener('mousemove', (e) => {
1826
+ if (this.isVolumeSliding) {
1827
+ this.handleVolumeChange(e);
1828
+ }
1829
+ if (this.isDragging && progressBar) {
1830
+ this.handleProgressChange(e);
1831
+ }
1832
+ });
1833
+
1834
+ document.addEventListener('mouseup', () => {
1835
+ if (this.isVolumeSliding) {
1836
+ this.isVolumeSliding = false;
1837
+ setTimeout(() => {
1838
+ if (!volumePanel?.matches(':hover') && !volumeBtn?.matches(':hover')) {
1839
+ volumePanel?.classList.remove('active');
1840
+ }
1841
+ }, 2000);
1842
+ }
1843
+ this.isDragging = false;
1844
+ });
1845
+
1846
+ // Progress bar
1847
+ progressBar?.addEventListener('click', (e) => this.handleProgressChange(e as MouseEvent));
1848
+ progressBar?.addEventListener('mousedown', () => this.isDragging = true);
1849
+
1850
+ // Update progress bar
1851
+ this.video.addEventListener('timeupdate', () => {
1852
+ const progressFilled = document.getElementById('uvf-progress-filled') as HTMLElement;
1853
+ const progressHandle = document.getElementById('uvf-progress-handle') as HTMLElement;
1854
+ const timeDisplay = document.getElementById('uvf-time-display');
1855
+
1856
+ if (this.video && progressFilled && progressHandle) {
1857
+ const percent = (this.video.currentTime / this.video.duration) * 100;
1858
+ progressFilled.style.width = percent + '%';
1859
+ progressHandle.style.left = percent + '%';
1860
+ }
1861
+
1862
+ if (timeDisplay && this.video) {
1863
+ const current = this.formatTime(this.video.currentTime);
1864
+ const duration = this.formatTime(this.video.duration);
1865
+ timeDisplay.textContent = `${current} / ${duration}`;
1866
+ }
1867
+ });
1868
+
1869
+ // Update buffered progress
1870
+ this.video.addEventListener('progress', () => {
1871
+ const progressBuffered = document.getElementById('uvf-progress-buffered') as HTMLElement;
1872
+ if (this.video && progressBuffered && this.video.buffered.length > 0) {
1873
+ const buffered = (this.video.buffered.end(0) / this.video.duration) * 100;
1874
+ progressBuffered.style.width = buffered + '%';
1875
+ }
1876
+ });
1877
+
1878
+ // Update volume display
1879
+ this.video.addEventListener('volumechange', () => {
1880
+ const volumeFill = document.getElementById('uvf-volume-fill') as HTMLElement;
1881
+ const volumeValue = document.getElementById('uvf-volume-value');
1882
+ const volumeIcon = document.getElementById('uvf-volume-icon');
1883
+ const muteIcon = document.getElementById('uvf-mute-icon');
1884
+
1885
+ if (this.video && volumeFill && volumeValue) {
1886
+ const percent = Math.round(this.video.volume * 100);
1887
+ volumeFill.style.width = percent + '%';
1888
+ volumeValue.textContent = String(percent);
1889
+ }
1890
+
1891
+ if (this.video && volumeIcon && muteIcon) {
1892
+ if (this.video.muted || this.video.volume === 0) {
1893
+ volumeIcon.style.display = 'none';
1894
+ muteIcon.style.display = 'block';
1895
+ } else {
1896
+ volumeIcon.style.display = 'block';
1897
+ muteIcon.style.display = 'none';
1898
+ }
1899
+ }
1900
+ });
1901
+
1902
+ // Fullscreen
1903
+ fullscreenBtn?.addEventListener('click', () => {
1904
+ if (!document.fullscreenElement) {
1905
+ this.enterFullscreen();
1906
+ } else {
1907
+ this.exitFullscreen();
1908
+ }
1909
+ });
1910
+
1911
+ // Loading states
1912
+ this.video.addEventListener('waiting', () => {
1913
+ const loading = document.getElementById('uvf-loading');
1914
+ if (loading) loading.classList.add('active');
1915
+ });
1916
+
1917
+ this.video.addEventListener('canplay', () => {
1918
+ const loading = document.getElementById('uvf-loading');
1919
+ if (loading) loading.classList.remove('active');
1920
+ });
1921
+
1922
+ // Auto-hide controls
1923
+ wrapper?.addEventListener('mousemove', () => {
1924
+ this.showControls();
1925
+ if (this.state.isPlaying) {
1926
+ this.scheduleHideControls();
1927
+ }
1928
+ });
1929
+
1930
+ wrapper?.addEventListener('mouseleave', () => {
1931
+ if (this.state.isPlaying) {
1932
+ this.hideControls();
1933
+ }
1934
+ });
1935
+
1936
+ this.controlsContainer?.addEventListener('mouseenter', () => {
1937
+ clearTimeout(this.hideControlsTimeout);
1938
+ });
1939
+
1940
+ this.controlsContainer?.addEventListener('mouseleave', () => {
1941
+ if (this.state.isPlaying) {
1942
+ this.scheduleHideControls();
1943
+ }
1944
+ });
1945
+
1946
+ // Progress bar hover tooltip
1947
+ progressBar?.addEventListener('mousemove', (e) => this.updateTimeTooltip(e as MouseEvent));
1948
+ progressBar?.addEventListener('mouseleave', () => this.hideTimeTooltip());
1949
+
1950
+ // Settings menu
1951
+ const settingsMenu = document.getElementById('uvf-settings-menu');
1952
+ settingsBtn?.addEventListener('click', (e) => {
1953
+ e.stopPropagation();
1954
+ settingsMenu?.classList.toggle('active');
1955
+ });
1956
+
1957
+ // Speed options
1958
+ document.querySelectorAll('.speed-option').forEach(option => {
1959
+ option.addEventListener('click', (e) => {
1960
+ const speed = parseFloat((e.target as HTMLElement).dataset.speed || '1');
1961
+ this.setSpeed(speed);
1962
+ settingsMenu?.classList.remove('active');
1963
+ });
1964
+ });
1965
+
1966
+ // Quality options
1967
+ document.querySelectorAll('.quality-option').forEach(option => {
1968
+ option.addEventListener('click', (e) => {
1969
+ const quality = (e.target as HTMLElement).dataset.quality;
1970
+ this.setQualityByLabel(quality || 'auto');
1971
+ settingsMenu?.classList.remove('active');
1972
+ });
1973
+ });
1974
+
1975
+ // PiP button
1976
+ const pipBtn = document.getElementById('uvf-pip-btn');
1977
+ pipBtn?.addEventListener('click', () => this.togglePiP());
1978
+
1979
+ // Top control buttons
1980
+ const castBtn = document.getElementById('uvf-cast-btn');
1981
+ const stopCastBtn = document.getElementById('uvf-stop-cast-btn');
1982
+ const playlistBtn = document.getElementById('uvf-playlist-btn');
1983
+ const shareBtn = document.getElementById('uvf-share-btn');
1984
+
1985
+ castBtn?.addEventListener('click', () => this.onCastButtonClick());
1986
+ stopCastBtn?.addEventListener('click', () => this.stopCasting());
1987
+ playlistBtn?.addEventListener('click', () => this.showNotification('Added to playlist'));
1988
+ shareBtn?.addEventListener('click', () => this.shareVideo());
1989
+
1990
+ // Hide settings menu when clicking outside
1991
+ document.addEventListener('click', (e) => {
1992
+ if (!(e.target as HTMLElement).closest('#uvf-settings-btn') &&
1993
+ !(e.target as HTMLElement).closest('#uvf-settings-menu')) {
1994
+ settingsMenu?.classList.remove('active');
1995
+ }
1996
+ });
1997
+ }
1998
+
1999
+ protected setupKeyboardShortcuts(): void {
2000
+ document.addEventListener('keydown', (e) => {
2001
+ // Don't handle if typing in an input
2002
+ if ((e.target as HTMLElement).tagName === 'INPUT' ||
2003
+ (e.target as HTMLElement).tagName === 'TEXTAREA') return;
2004
+
2005
+
2006
+ let shortcutText = '';
2007
+
2008
+ switch(e.key) {
2009
+ case ' ':
2010
+ case 'k':
2011
+ e.preventDefault();
2012
+ this.togglePlayPause();
2013
+ shortcutText = this.state.isPlaying ? 'Pause' : 'Play';
2014
+ break;
2015
+ case 'ArrowLeft':
2016
+ e.preventDefault();
2017
+ this.seek(Math.max(0, this.video!.currentTime - 10));
2018
+ shortcutText = '-10s';
2019
+ break;
2020
+ case 'ArrowRight':
2021
+ e.preventDefault();
2022
+ this.seek(Math.min(this.video!.duration, this.video!.currentTime + 10));
2023
+ shortcutText = '+10s';
2024
+ break;
2025
+ case 'ArrowUp':
2026
+ e.preventDefault();
2027
+ this.changeVolume(0.1);
2028
+ if (this.isCasting && this.remotePlayer) {
2029
+ shortcutText = `Volume ${Math.round(((this.remotePlayer.volumeLevel || 0) * 100))}%`;
2030
+ } else {
2031
+ shortcutText = `Volume ${Math.round((this.video?.volume || 0) * 100)}%`;
2032
+ }
2033
+ break;
2034
+ case 'ArrowDown':
2035
+ e.preventDefault();
2036
+ this.changeVolume(-0.1);
2037
+ if (this.isCasting && this.remotePlayer) {
2038
+ shortcutText = `Volume ${Math.round(((this.remotePlayer.volumeLevel || 0) * 100))}%`;
2039
+ } else {
2040
+ shortcutText = `Volume ${Math.round((this.video?.volume || 0) * 100)}%`;
2041
+ }
2042
+ break;
2043
+ case 'm':
2044
+ e.preventDefault();
2045
+ this.toggleMuteAction();
2046
+ if (this.isCasting && this.remotePlayer) {
2047
+ shortcutText = this.remotePlayer.isMuted ? 'Muted' : 'Unmuted';
2048
+ } else {
2049
+ shortcutText = this.video?.muted ? 'Muted' : 'Unmuted';
2050
+ }
2051
+ break;
2052
+ case 'f':
2053
+ e.preventDefault();
2054
+ if (!document.fullscreenElement) {
2055
+ this.enterFullscreen();
2056
+ shortcutText = 'Fullscreen';
2057
+ } else {
2058
+ this.exitFullscreen();
2059
+ shortcutText = 'Exit Fullscreen';
2060
+ }
2061
+ break;
2062
+ case 'p':
2063
+ e.preventDefault();
2064
+ this.togglePiP();
2065
+ shortcutText = 'Picture-in-Picture';
2066
+ break;
2067
+ case '0':
2068
+ case '1':
2069
+ case '2':
2070
+ case '3':
2071
+ case '4':
2072
+ case '5':
2073
+ case '6':
2074
+ case '7':
2075
+ case '8':
2076
+ case '9':
2077
+ e.preventDefault();
2078
+ const percent = parseInt(e.key) * 10;
2079
+ if (this.video) {
2080
+ this.video.currentTime = (this.video.duration * percent) / 100;
2081
+ shortcutText = `${percent}%`;
2082
+ }
2083
+ break;
2084
+ }
2085
+
2086
+ if (shortcutText) {
2087
+ this.showShortcutIndicator(shortcutText);
2088
+ }
2089
+ });
2090
+ }
2091
+
2092
+ protected setupWatermark(): void {
2093
+ if (!this.watermarkCanvas) return;
2094
+
2095
+ const ctx = this.watermarkCanvas.getContext('2d');
2096
+ if (!ctx) return;
2097
+
2098
+ const renderWatermark = () => {
2099
+ const container = this.watermarkCanvas!.parentElement;
2100
+ if (!container) return;
2101
+
2102
+ this.watermarkCanvas!.width = container.offsetWidth;
2103
+ this.watermarkCanvas!.height = container.offsetHeight;
2104
+
2105
+ ctx.clearRect(0, 0, this.watermarkCanvas!.width, this.watermarkCanvas!.height);
2106
+
2107
+ // Gradient text effect using theme
2108
+ const wrapper = this.playerWrapper as HTMLElement | null;
2109
+ let c1 = '#ff0000';
2110
+ let c2 = '#ff4d4f';
2111
+ try {
2112
+ if (wrapper) {
2113
+ const styles = getComputedStyle(wrapper);
2114
+ const v1 = styles.getPropertyValue('--uvf-accent-1').trim();
2115
+ const v2 = styles.getPropertyValue('--uvf-accent-2').trim();
2116
+ if (v1) c1 = v1;
2117
+ if (v2) c2 = v2;
2118
+ }
2119
+ } catch (_) {}
2120
+ const gradient = ctx.createLinearGradient(0, 0, 200, 0);
2121
+ gradient.addColorStop(0, c1);
2122
+ gradient.addColorStop(1, c2);
2123
+ ctx.save();
2124
+ ctx.globalAlpha = 0.3;
2125
+ ctx.font = '14px Arial';
2126
+ ctx.fillStyle = gradient;
2127
+ ctx.textAlign = 'left';
2128
+
2129
+ const text = `PREMIUM • ${new Date().toLocaleTimeString()}`;
2130
+ const x = 20 + Math.random() * (this.watermarkCanvas!.width - 200);
2131
+ const y = 40 + Math.random() * (this.watermarkCanvas!.height - 80);
2132
+
2133
+ ctx.fillText(text, x, y);
2134
+ ctx.restore();
2135
+ };
2136
+
2137
+ setInterval(renderWatermark, 5000);
2138
+ renderWatermark();
2139
+ }
2140
+
2141
+ public setPaywallConfig(config: any) {
2142
+ try {
2143
+ if (!config) return;
2144
+ if (this.paywallController && typeof this.paywallController.updateConfig === 'function') {
2145
+ this.paywallController.updateConfig(config);
2146
+ } else {
2147
+ // lazy-init if not created yet
2148
+ if (config.enabled) {
2149
+ import('./paywall/PaywallController').then((m: any) => {
2150
+ this.paywallController = new m.PaywallController(config, {
2151
+ getOverlayContainer: () => this.playerWrapper,
2152
+ onResume: () => { try { this.play(); } catch(_) {} }
2153
+ });
2154
+ }).catch(() => {});
2155
+ }
2156
+ }
2157
+ } catch (_) {}
2158
+ }
2159
+
2160
+ private togglePlayPause(): void {
2161
+ if (this.video?.paused) {
2162
+ this.play();
2163
+ } else {
2164
+ this.pause();
2165
+ }
2166
+ }
2167
+
2168
+ // Enforce free preview gate for local or casting playback
2169
+ private enforceFreePreviewGate(current: number, fromSeek: boolean = false): void {
2170
+ try {
2171
+ const lim = Number(this.config.freeDuration || 0);
2172
+ if (!(lim > 0)) return;
2173
+ if (this.previewGateHit && !fromSeek) return;
2174
+ if (current >= lim - 0.01 && !this.previewGateHit) {
2175
+ this.previewGateHit = true;
2176
+ this.showNotification('Free preview ended. Please rent to continue.');
2177
+ this.emit('onFreePreviewEnded');
2178
+ }
2179
+ if (current >= lim - 0.01) {
2180
+ if (this.isCasting && this.remoteController) {
2181
+ try {
2182
+ if (this.remotePlayer && this.remotePlayer.isPaused === false) {
2183
+ this.remoteController.playOrPause();
2184
+ }
2185
+ } catch (_) {}
2186
+ } else if (this.video) {
2187
+ try { this.video.pause(); } catch (_) {}
2188
+ try {
2189
+ if (fromSeek || (this.video.currentTime > lim)) {
2190
+ this.video.currentTime = Math.max(0, lim - 0.1);
2191
+ }
2192
+ } catch (_) {}
2193
+ }
2194
+ }
2195
+ } catch (_) {}
2196
+ }
2197
+
2198
+ // Public runtime controls for free preview
2199
+ public setFreeDuration(seconds: number): void {
2200
+ try {
2201
+ const s = Math.max(0, Number(seconds) || 0);
2202
+ (this.config as any).freeDuration = s;
2203
+ // Reset gate if we extended duration below current gate
2204
+ if (s === 0 || (this.video && this.video.currentTime < s)) {
2205
+ this.previewGateHit = false;
2206
+ }
2207
+ // If already past new limit, enforce immediately
2208
+ const cur = this.video ? (this.video.currentTime || 0) : 0;
2209
+ this.enforceFreePreviewGate(cur, true);
2210
+ } catch (_) {}
2211
+ }
2212
+ public resetFreePreviewGate(): void {
2213
+ this.previewGateHit = false;
2214
+ }
2215
+
2216
+ private toggleMuteAction(): void {
2217
+ if (this.isCasting && this.remoteController) {
2218
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
2219
+ return;
2220
+ }
2221
+ if (this.video?.muted) {
2222
+ this.unmute();
2223
+ } else {
2224
+ this.mute();
2225
+ }
2226
+ }
2227
+
2228
+ private handleVolumeChange(e: MouseEvent): void {
2229
+ const slider = document.getElementById('uvf-volume-slider');
2230
+ if (!slider) return;
2231
+
2232
+ const rect = slider.getBoundingClientRect();
2233
+ const x = e.clientX - rect.left;
2234
+ const width = rect.width;
2235
+ const percent = Math.max(0, Math.min(1, x / width));
2236
+
2237
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
2238
+ try {
2239
+ if (this.remotePlayer.isMuted) {
2240
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
2241
+ this.remotePlayer.isMuted = false;
2242
+ }
2243
+ this.remotePlayer.volumeLevel = percent;
2244
+ this.remoteController.setVolumeLevel();
2245
+ } catch (_) {}
2246
+ this.updateVolumeUIFromRemote();
2247
+ } else if (this.video) {
2248
+ this.setVolume(percent);
2249
+ this.video.muted = false;
2250
+ }
2251
+ }
2252
+
2253
+ private handleProgressChange(e: MouseEvent): void {
2254
+ const progressBar = document.getElementById('uvf-progress-bar');
2255
+ if (!progressBar || !this.video) return;
2256
+
2257
+ const rect = progressBar.getBoundingClientRect();
2258
+ const percent = (e.clientX - rect.left) / rect.width;
2259
+ const time = percent * this.video.duration;
2260
+
2261
+ this.seek(time);
2262
+ }
2263
+
2264
+ private formatTime(seconds: number): string {
2265
+ if (!seconds || isNaN(seconds)) return '00:00';
2266
+
2267
+ const hours = Math.floor(seconds / 3600);
2268
+ const minutes = Math.floor((seconds % 3600) / 60);
2269
+ const secs = Math.floor(seconds % 60);
2270
+
2271
+ if (hours > 0) {
2272
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
2273
+ } else {
2274
+ return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
2275
+ }
2276
+ }
2277
+
2278
+ private showControls(): void {
2279
+ clearTimeout(this.hideControlsTimeout);
2280
+ const wrapper = this.container?.querySelector('.uvf-player-wrapper');
2281
+ if (wrapper) {
2282
+ wrapper.classList.add('controls-visible');
2283
+ wrapper.classList.remove('no-cursor');
2284
+ }
2285
+ }
2286
+
2287
+ private hideControls(): void {
2288
+ if (!this.state.isPlaying) return;
2289
+
2290
+ const wrapper = this.container?.querySelector('.uvf-player-wrapper');
2291
+ if (wrapper) {
2292
+ wrapper.classList.remove('controls-visible');
2293
+ wrapper.classList.add('no-cursor');
2294
+ }
2295
+ }
2296
+
2297
+ private scheduleHideControls(): void {
2298
+ if (!this.state.isPlaying) return;
2299
+
2300
+ clearTimeout(this.hideControlsTimeout);
2301
+ this.hideControlsTimeout = setTimeout(() => {
2302
+ if (this.state.isPlaying && !this.controlsContainer?.matches(':hover')) {
2303
+ this.hideControls();
2304
+ }
2305
+ }, 3000);
2306
+ }
2307
+
2308
+ private updateTimeTooltip(e: MouseEvent): void {
2309
+ const progressBar = document.getElementById('uvf-progress-bar');
2310
+ const timeTooltip = document.getElementById('uvf-time-tooltip');
2311
+ if (!progressBar || !timeTooltip || !this.video) return;
2312
+
2313
+ const rect = progressBar.getBoundingClientRect();
2314
+ const percent = (e.clientX - rect.left) / rect.width;
2315
+ const time = percent * this.video.duration;
2316
+
2317
+ timeTooltip.textContent = this.formatTime(time);
2318
+ timeTooltip.style.left = `${e.clientX - rect.left}px`;
2319
+ timeTooltip.style.opacity = '1';
2320
+ }
2321
+
2322
+ private hideTimeTooltip(): void {
2323
+ const timeTooltip = document.getElementById('uvf-time-tooltip');
2324
+ if (timeTooltip) {
2325
+ timeTooltip.style.opacity = '0';
2326
+ }
2327
+ }
2328
+
2329
+
2330
+ private showShortcutIndicator(text: string): void {
2331
+ const el = document.getElementById('uvf-shortcut-indicator');
2332
+ if (!el) return;
2333
+ try {
2334
+ const resetAnim = () => {
2335
+ el.classList.remove('active');
2336
+ // force reflow to restart animation
2337
+ void (el as HTMLElement).offsetWidth;
2338
+ el.classList.add('active');
2339
+ };
2340
+ const setIcon = (svg: string) => {
2341
+ el.classList.add('uvf-ki-icon');
2342
+ el.innerHTML = `<div class="uvf-ki uvf-ki-icon">${svg}</div>`;
2343
+ resetAnim();
2344
+ };
2345
+ const setSkip = (dir: 'fwd'|'back', num: number) => {
2346
+ el.classList.add('uvf-ki-icon');
2347
+ const svg = dir === 'fwd'
2348
+ ? `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12.01 19c-3.31 0-6-2.69-6-6s2.69-6 6-6V5l5 5-5 5V9c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4h2c0 3.31-2.69 6-6 6z"/></svg>`
2349
+ : `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`;
2350
+ el.innerHTML = `<div class="uvf-ki uvf-ki-skip"><div class="uvf-ki-skip-num">${num}</div>${svg}</div>`;
2351
+ resetAnim();
2352
+ };
2353
+ const setVolume = (percent: number, muted: boolean = false) => {
2354
+ el.classList.remove('uvf-ki-icon');
2355
+ const p = Math.max(0, Math.min(100, Math.round(percent)));
2356
+ const icon = muted ? `
2357
+ <svg viewBox="0 0 24 24" aria-hidden="true">
2358
+ <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
2359
+ </svg>` : `
2360
+ <svg viewBox="0 0 24 24" aria-hidden="true">
2361
+ <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
2362
+ </svg>`;
2363
+ el.innerHTML = `
2364
+ <div class="uvf-ki uvf-ki-volume" role="status" aria-live="polite">
2365
+ <div class="uvf-ki-vol-icon">${icon}</div>
2366
+ <div class="uvf-ki-vol-bar"><div class="uvf-ki-vol-fill" style="width:${p}%"></div></div>
2367
+ <div class="uvf-ki-vol-text">${p}%</div>
2368
+ </div>`;
2369
+ resetAnim();
2370
+ };
2371
+ const setText = (t: string) => {
2372
+ el.classList.remove('uvf-ki-icon');
2373
+ el.innerHTML = `<div class="uvf-ki uvf-ki-text">${t}</div>`;
2374
+ resetAnim();
2375
+ };
2376
+
2377
+ // Map text cues to icon overlays (YouTube-like)
2378
+ if (text === 'Play') {
2379
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`);
2380
+ } else if (text === 'Pause') {
2381
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`);
2382
+ } else if (text === '+10s') {
2383
+ setSkip('fwd', 10);
2384
+ } else if (text === '-10s') {
2385
+ setSkip('back', 10);
2386
+ } else if (/^Volume\s+(\d+)%$/.test(text)) {
2387
+ const m = text.match(/^Volume\s+(\d+)%$/);
2388
+ const val = m ? parseInt(m[1], 10) : 0;
2389
+ setVolume(val);
2390
+ } else if (text === 'Muted' || text === 'Unmuted') {
2391
+ const muted = text === 'Muted';
2392
+ const level = (this.isCasting && this.remotePlayer) ? Math.round(((this.remotePlayer.volumeLevel || 0) * 100)) : Math.round((this.video?.volume || 0) * 100);
2393
+ setVolume(level, muted);
2394
+ } else if (text === 'Fullscreen') {
2395
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`);
2396
+ } else if (text === 'Exit Fullscreen') {
2397
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`);
2398
+ } else if (text === 'Picture-in-Picture') {
2399
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>`);
2400
+ } else if (/^\d+%$/.test(text)) {
2401
+ setText(text);
2402
+ } else {
2403
+ setText(text);
2404
+ }
2405
+
2406
+ // auto-hide after animation
2407
+ clearTimeout(this._kiTo);
2408
+ this._kiTo = setTimeout(() => {
2409
+ try { el.classList.remove('active'); } catch (_) {}
2410
+ }, 1000);
2411
+ } catch (err) {
2412
+ try {
2413
+ (el as HTMLElement).textContent = String(text || '');
2414
+ el.classList.add('active');
2415
+ setTimeout(() => el.classList.remove('active'), 1000);
2416
+ } catch(_) {}
2417
+ }
2418
+ }
2419
+
2420
+ public setSettingsScrollbarStyle(mode: 'default' | 'compact' | 'overlay'): void {
2421
+ const wrapper = this.playerWrapper;
2422
+ if (!wrapper) return;
2423
+ wrapper.classList.remove('uvf-scrollbar-compact', 'uvf-scrollbar-overlay');
2424
+ switch (mode) {
2425
+ case 'compact':
2426
+ wrapper.classList.add('uvf-scrollbar-compact');
2427
+ break;
2428
+ case 'overlay':
2429
+ wrapper.classList.add('uvf-scrollbar-overlay');
2430
+ break;
2431
+ default:
2432
+ // default
2433
+ break;
2434
+ }
2435
+ }
2436
+
2437
+ public setSettingsScrollbarConfig(options: { widthPx?: number; intensity?: number }): void {
2438
+ const wrapper = this.playerWrapper;
2439
+ if (!wrapper) return;
2440
+ const { widthPx, intensity } = options || {};
2441
+
2442
+ if (typeof widthPx === 'number' && isFinite(widthPx)) {
2443
+ const w = Math.max(4, Math.min(16, Math.round(widthPx)));
2444
+ wrapper.style.setProperty('--uvf-scrollbar-width', `${w}px`);
2445
+ }
2446
+
2447
+ if (typeof intensity === 'number' && isFinite(intensity)) {
2448
+ const i = Math.max(0, Math.min(1, intensity));
2449
+ const s1 = 0.35 * i;
2450
+ const e1 = 0.45 * i;
2451
+ const s2 = 0.50 * i;
2452
+ const e2 = 0.60 * i;
2453
+ wrapper.style.setProperty('--uvf-scrollbar-thumb-start', `rgba(255,0,0,${s1.toFixed(3)})`);
2454
+ wrapper.style.setProperty('--uvf-scrollbar-thumb-end', `rgba(255,0,0,${e1.toFixed(3)})`);
2455
+ wrapper.style.setProperty('--uvf-scrollbar-thumb-hover-start', `rgba(255,0,0,${s2.toFixed(3)})`);
2456
+ wrapper.style.setProperty('--uvf-scrollbar-thumb-hover-end', `rgba(255,0,0,${e2.toFixed(3)})`);
2457
+ wrapper.style.setProperty('--uvf-firefox-scrollbar-color', `rgba(255,255,255,${(0.25 * i).toFixed(3)})`);
2458
+ }
2459
+ }
2460
+
2461
+ private applyScrollbarPreferencesFromDataset(): void {
2462
+ const container = this.container as HTMLElement | null;
2463
+ if (!container) return;
2464
+ const ds = container.dataset || {};
2465
+
2466
+ const stylePref = (ds.scrollbarStyle || '').toLowerCase();
2467
+ if (stylePref === 'compact' || stylePref === 'overlay' || stylePref === 'default') {
2468
+ this.setSettingsScrollbarStyle(stylePref as 'default' | 'compact' | 'overlay');
2469
+ }
2470
+
2471
+ const width = Number(ds.scrollbarWidth);
2472
+ const intensity = Number(ds.scrollbarIntensity);
2473
+ const options: { widthPx?: number; intensity?: number } = {};
2474
+ if (Number.isFinite(width)) options.widthPx = width;
2475
+ if (Number.isFinite(intensity)) options.intensity = intensity;
2476
+ if (options.widthPx !== undefined || options.intensity !== undefined) {
2477
+ this.setSettingsScrollbarConfig(options);
2478
+ }
2479
+ }
2480
+
2481
+ // Theme API: set CSS variables on the wrapper to apply dynamic colors
2482
+ public setTheme(theme: any): void {
2483
+ const wrapper = this.playerWrapper;
2484
+ if (!wrapper) return;
2485
+ try {
2486
+ let accent1: string | null = null;
2487
+ let accent2: string | null = null;
2488
+ let iconColor: string | null = null;
2489
+ let textPrimary: string | null = null;
2490
+ let textSecondary: string | null = null;
2491
+
2492
+ if (typeof theme === 'string') {
2493
+ accent1 = theme;
2494
+ } else if (theme && typeof theme === 'object') {
2495
+ accent1 = theme.accent || null;
2496
+ accent2 = theme.accent2 || null;
2497
+ iconColor = theme.iconColor || null;
2498
+ textPrimary = theme.textPrimary || null;
2499
+ textSecondary = theme.textSecondary || null;
2500
+ }
2501
+
2502
+ if (accent1) wrapper.style.setProperty('--uvf-accent-1', accent1);
2503
+ // Derive accent2 if missing
2504
+ if (!accent2 && accent1) {
2505
+ const rgb = this._parseRgb(accent1);
2506
+ if (rgb) {
2507
+ const lighter = this._lightenRgb(rgb, 0.35);
2508
+ accent2 = this._rgbToString(lighter);
2509
+ } else {
2510
+ // Fallback: use the same accent for both ends of the gradient
2511
+ accent2 = accent1;
2512
+ }
2513
+ }
2514
+ if (accent2) wrapper.style.setProperty('--uvf-accent-2', accent2);
2515
+
2516
+ // Provide a translucent version of accent1 for badges
2517
+ if (accent1) {
2518
+ const a20 = this._toRgba(accent1, 0.2);
2519
+ if (a20) wrapper.style.setProperty('--uvf-accent-1-20', a20);
2520
+ }
2521
+
2522
+ if (iconColor) wrapper.style.setProperty('--uvf-icon-color', iconColor);
2523
+ if (textPrimary) wrapper.style.setProperty('--uvf-text-primary', textPrimary);
2524
+ if (textSecondary) wrapper.style.setProperty('--uvf-text-secondary', textSecondary);
2525
+ } catch (_) {
2526
+ // ignore
2527
+ }
2528
+ }
2529
+
2530
+ private _parseRgb(input: string): { r: number; g: number; b: number } | null {
2531
+ try {
2532
+ const s = (input || '').trim().toLowerCase();
2533
+ // #rrggbb or #rgb
2534
+ if (s.startsWith('#')) {
2535
+ const hex = s.substring(1);
2536
+ if (hex.length === 3) {
2537
+ const r = parseInt(hex[0] + hex[0], 16);
2538
+ const g = parseInt(hex[1] + hex[1], 16);
2539
+ const b = parseInt(hex[2] + hex[2], 16);
2540
+ return { r, g, b };
2541
+ }
2542
+ if (hex.length === 6) {
2543
+ const r = parseInt(hex.substring(0, 2), 16);
2544
+ const g = parseInt(hex.substring(2, 4), 16);
2545
+ const b = parseInt(hex.substring(4, 6), 16);
2546
+ return { r, g, b };
2547
+ }
2548
+ }
2549
+ // rgb(a)
2550
+ if (s.startsWith('rgb(') || s.startsWith('rgba(')) {
2551
+ const nums = s.replace(/rgba?\(/, '').replace(/\)/, '').split(',').map(x => parseFloat(x.trim()));
2552
+ if (nums.length >= 3) {
2553
+ return { r: Math.round(nums[0]), g: Math.round(nums[1]), b: Math.round(nums[2]) };
2554
+ }
2555
+ }
2556
+ } catch (_) {}
2557
+ return null;
2558
+ }
2559
+
2560
+ private _rgbToString(rgb: { r: number; g: number; b: number }): string {
2561
+ const c = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
2562
+ return `rgb(${c(rgb.r)}, ${c(rgb.g)}, ${c(rgb.b)})`;
2563
+ }
2564
+
2565
+ private _lightenRgb(rgb: { r: number; g: number; b: number }, amount: number): { r: number; g: number; b: number } {
2566
+ const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
2567
+ const amt = Math.max(0, Math.min(1, amount));
2568
+ return {
2569
+ r: clamp(rgb.r + (255 - rgb.r) * amt),
2570
+ g: clamp(rgb.g + (255 - rgb.g) * amt),
2571
+ b: clamp(rgb.b + (255 - rgb.b) * amt),
2572
+ };
2573
+ }
2574
+
2575
+ private _toRgba(input: string, alpha: number): string | null {
2576
+ const rgb = this._parseRgb(input);
2577
+ if (!rgb) return null;
2578
+ const a = Math.max(0, Math.min(1, alpha));
2579
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;
2580
+ }
2581
+
2582
+ private changeVolume(delta: number): void {
2583
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
2584
+ const cur = this.remotePlayer.volumeLevel || 0;
2585
+ const next = Math.max(0, Math.min(1, cur + delta));
2586
+ try {
2587
+ if (this.remotePlayer.isMuted) {
2588
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
2589
+ this.remotePlayer.isMuted = false;
2590
+ }
2591
+ this.remotePlayer.volumeLevel = next;
2592
+ this.remoteController.setVolumeLevel();
2593
+ } catch (_) {}
2594
+ this.updateVolumeUIFromRemote();
2595
+ return;
2596
+ }
2597
+ if (!this.video) return;
2598
+ this.video.volume = Math.max(0, Math.min(1, this.video.volume + delta));
2599
+ }
2600
+
2601
+ private setSpeed(speed: number): void {
2602
+ if (!this.video) return;
2603
+ this.video.playbackRate = speed;
2604
+
2605
+ // Update UI
2606
+ document.querySelectorAll('.speed-option').forEach(option => {
2607
+ option.classList.remove('active');
2608
+ if (parseFloat((option as HTMLElement).dataset.speed || '1') === speed) {
2609
+ option.classList.add('active');
2610
+ }
2611
+ });
2612
+ }
2613
+
2614
+ private setQualityByLabel(quality: string): void {
2615
+ const qualityBadge = document.getElementById('uvf-quality-badge');
2616
+
2617
+ // Update UI
2618
+ document.querySelectorAll('.quality-option').forEach(option => {
2619
+ option.classList.remove('active');
2620
+ if ((option as HTMLElement).dataset.quality === quality) {
2621
+ option.classList.add('active');
2622
+ }
2623
+ });
2624
+
2625
+ // Update badge
2626
+ if (qualityBadge) {
2627
+ if (quality === 'auto') {
2628
+ qualityBadge.textContent = 'AUTO';
2629
+ } else {
2630
+ qualityBadge.textContent = quality + 'p';
2631
+ }
2632
+ }
2633
+
2634
+ // If we have actual quality levels from HLS/DASH, apply them
2635
+ if (quality !== 'auto' && this.qualities.length > 0) {
2636
+ const qualityLevel = this.qualities.find(q => q.label === quality + 'p');
2637
+ if (qualityLevel) {
2638
+ this.setQuality(qualityLevel.index);
2639
+ }
2640
+ } else if (quality === 'auto') {
2641
+ this.setAutoQuality(true);
2642
+ }
2643
+ }
2644
+
2645
+ private async togglePiP(): Promise<void> {
2646
+ try {
2647
+ if ((document as any).pictureInPictureElement) {
2648
+ await this.exitPictureInPicture();
2649
+ } else {
2650
+ await this.enterPictureInPicture();
2651
+ }
2652
+ } catch (error) {
2653
+ console.error('PiP toggle failed:', error);
2654
+ }
2655
+ }
2656
+
2657
+ private setupCastContextSafe(): void {
2658
+ try {
2659
+ const castNs = (window as any).cast;
2660
+ if (castNs && castNs.framework) {
2661
+ this.setupCastContext();
2662
+ }
2663
+ } catch (_) {}
2664
+ }
2665
+
2666
+ private setupCastContext(): void {
2667
+ if (this.castContext) return;
2668
+ try {
2669
+ const castNs = (window as any).cast;
2670
+ this.castContext = castNs.framework.CastContext.getInstance();
2671
+ const chromeNs = (window as any).chrome;
2672
+ const options: any = { receiverApplicationId: chromeNs?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID };
2673
+ try {
2674
+ const autoJoin = chromeNs?.cast?.AutoJoinPolicy?.ORIGIN_SCOPED;
2675
+ if (autoJoin) options.autoJoinPolicy = autoJoin;
2676
+ } catch (_) {}
2677
+ this.castContext.setOptions(options);
2678
+ this.castContext.addEventListener(
2679
+ castNs.framework.CastContextEventType.SESSION_STATE_CHANGED,
2680
+ (ev: any) => {
2681
+ const state = ev.sessionState;
2682
+ if (state === castNs.framework.SessionState.SESSION_STARTED ||
2683
+ state === castNs.framework.SessionState.SESSION_RESUMED) {
2684
+ this.enableCastRemoteControl();
2685
+ } else if (state === castNs.framework.SessionState.SESSION_ENDED) {
2686
+ this.disableCastRemoteControl();
2687
+ }
2688
+ }
2689
+ );
2690
+ } catch (err) {
2691
+ if (this.config.debug) console.warn('[Cast] setupCastContext failed', err);
2692
+ }
2693
+ }
2694
+
2695
+ private enableCastRemoteControl(): void {
2696
+ try {
2697
+ const castNs = (window as any).cast;
2698
+ if (!castNs || !castNs.framework) return;
2699
+ const session = castNs.framework.CastContext.getInstance().getCurrentSession();
2700
+ if (!session) return;
2701
+ if (!this.remotePlayer) this.remotePlayer = new castNs.framework.RemotePlayer();
2702
+ if (!this.remoteController) {
2703
+ this.remoteController = new castNs.framework.RemotePlayerController(this.remotePlayer);
2704
+ this._bindRemotePlayerEvents();
2705
+ }
2706
+ this.isCasting = true;
2707
+ try { this.video?.pause(); } catch (_) {}
2708
+ this._syncUIFromRemote();
2709
+ this._syncCastButtons();
2710
+ } catch (err) {
2711
+ if (this.config.debug) console.warn('[Cast] enableCastRemoteControl failed', err);
2712
+ }
2713
+ }
2714
+
2715
+ private disableCastRemoteControl(): void {
2716
+ this.isCasting = false;
2717
+ this._syncCastButtons();
2718
+ }
2719
+
2720
+ private _bindRemotePlayerEvents(): void {
2721
+ const castNs = (window as any).cast;
2722
+ if (!this.remoteController || !castNs) return;
2723
+ const RPET = castNs.framework.RemotePlayerEventType;
2724
+ const rc = this.remoteController;
2725
+
2726
+ rc.addEventListener(RPET.IS_PAUSED_CHANGED, () => {
2727
+ if (!this.isCasting) return;
2728
+ if (this.remotePlayer && this.remotePlayer.isPaused === false) {
2729
+ // reflect playing UI
2730
+ const playIcon = document.getElementById('uvf-play-icon');
2731
+ const pauseIcon = document.getElementById('uvf-pause-icon');
2732
+ if (playIcon) playIcon.style.display = 'none';
2733
+ if (pauseIcon) pauseIcon.style.display = 'block';
2734
+ } else {
2735
+ const playIcon = document.getElementById('uvf-play-icon');
2736
+ const pauseIcon = document.getElementById('uvf-pause-icon');
2737
+ if (playIcon) playIcon.style.display = 'block';
2738
+ if (pauseIcon) pauseIcon.style.display = 'none';
2739
+ }
2740
+ });
2741
+
2742
+ rc.addEventListener(RPET.CURRENT_TIME_CHANGED, () => {
2743
+ if (!this.isCasting) return;
2744
+ const progressFilled = document.getElementById('uvf-progress-filled') as HTMLElement;
2745
+ const progressHandle = document.getElementById('uvf-progress-handle') as HTMLElement;
2746
+ const timeDisplay = document.getElementById('uvf-time-display');
2747
+ const duration = this.remotePlayer?.duration || 0;
2748
+ const current = Math.max(0, Math.min(this.remotePlayer?.currentTime || 0, duration));
2749
+ const percent = duration > 0 ? (current / duration) * 100 : 0;
2750
+ if (progressFilled) progressFilled.style.width = percent + '%';
2751
+ if (progressHandle) progressHandle.style.left = percent + '%';
2752
+ if (timeDisplay) (timeDisplay as HTMLElement).textContent = `${this.formatTime(current)} / ${this.formatTime(duration)}`;
2753
+ // Enforce gate while casting
2754
+ this.enforceFreePreviewGate(current);
2755
+ });
2756
+
2757
+ rc.addEventListener(RPET.DURATION_CHANGED, () => {
2758
+ if (!this.isCasting) return;
2759
+ const timeDisplay = document.getElementById('uvf-time-display');
2760
+ const duration = this.remotePlayer?.duration || 0;
2761
+ if (timeDisplay) (timeDisplay as HTMLElement).textContent = `${this.formatTime(this.remotePlayer?.currentTime || 0)} / ${this.formatTime(duration)}`;
2762
+ });
2763
+
2764
+ rc.addEventListener(RPET.IS_MUTED_CHANGED, () => {
2765
+ if (!this.isCasting) return;
2766
+ this.updateVolumeUIFromRemote();
2767
+ });
2768
+
2769
+ rc.addEventListener(RPET.VOLUME_LEVEL_CHANGED, () => {
2770
+ if (!this.isCasting) return;
2771
+ this.updateVolumeUIFromRemote();
2772
+ });
2773
+
2774
+ rc.addEventListener(RPET.IS_CONNECTED_CHANGED, () => {
2775
+ if (!this.remotePlayer?.isConnected) {
2776
+ this.disableCastRemoteControl();
2777
+ }
2778
+ });
2779
+ }
2780
+
2781
+ private updateVolumeUIFromRemote(): void {
2782
+ const volumeFill = document.getElementById('uvf-volume-fill') as HTMLElement;
2783
+ const volumeValue = document.getElementById('uvf-volume-value');
2784
+ const volumeIcon = document.getElementById('uvf-volume-icon');
2785
+ const muteIcon = document.getElementById('uvf-mute-icon');
2786
+ const level = Math.round(((this.remotePlayer?.volumeLevel || 0) * 100));
2787
+ if (volumeFill) volumeFill.style.width = level + '%';
2788
+ if (volumeValue) (volumeValue as HTMLElement).textContent = String(level);
2789
+ const isMuted = !!this.remotePlayer?.isMuted || level === 0;
2790
+ if (volumeIcon && muteIcon) {
2791
+ if (isMuted) {
2792
+ (volumeIcon as HTMLElement).style.display = 'none';
2793
+ (muteIcon as HTMLElement).style.display = 'block';
2794
+ } else {
2795
+ (volumeIcon as HTMLElement).style.display = 'block';
2796
+ (muteIcon as HTMLElement).style.display = 'none';
2797
+ }
2798
+ }
2799
+ }
2800
+
2801
+ private _syncUIFromRemote(): void {
2802
+ const duration = this.remotePlayer?.duration || 0;
2803
+ const current = this.remotePlayer?.currentTime || 0;
2804
+ const percent = duration > 0 ? (current / duration) * 100 : 0;
2805
+ const progressFilled = document.getElementById('uvf-progress-filled') as HTMLElement;
2806
+ const progressHandle = document.getElementById('uvf-progress-handle') as HTMLElement;
2807
+ const timeDisplay = document.getElementById('uvf-time-display');
2808
+ if (progressFilled) progressFilled.style.width = percent + '%';
2809
+ if (progressHandle) progressHandle.style.left = percent + '%';
2810
+ if (timeDisplay) (timeDisplay as HTMLElement).textContent = `${this.formatTime(current)} / ${this.formatTime(duration)}`;
2811
+ this.updateVolumeUIFromRemote();
2812
+ // Also enforce gate in case of immediate resume
2813
+ this.enforceFreePreviewGate(current);
2814
+ }
2815
+
2816
+ private _syncCastButtons(): void {
2817
+ const castBtn = document.getElementById('uvf-cast-btn');
2818
+ const stopBtn = document.getElementById('uvf-stop-cast-btn');
2819
+ const wrapper = this.playerWrapper || this.container?.querySelector('.uvf-player-wrapper');
2820
+ if (stopBtn) (stopBtn as HTMLElement).style.display = this.isCasting ? 'inline-flex' : 'none';
2821
+ if (castBtn) {
2822
+ if (this.isCasting) {
2823
+ castBtn.classList.add('cast-grey');
2824
+ let title = 'Pick device';
2825
+ try {
2826
+ const castNs = (window as any).cast;
2827
+ const sess = castNs?.framework?.CastContext?.getInstance()?.getCurrentSession?.();
2828
+ const dev = sess && sess.getCastDevice ? sess.getCastDevice() : null;
2829
+ if (dev && dev.friendlyName) title += ` (${dev.friendlyName})`;
2830
+ } catch (_) {}
2831
+ castBtn.setAttribute('title', title);
2832
+ castBtn.setAttribute('aria-label', title);
2833
+ } else {
2834
+ castBtn.classList.remove('cast-grey');
2835
+ castBtn.setAttribute('title', 'Cast');
2836
+ castBtn.setAttribute('aria-label', 'Cast');
2837
+ }
2838
+ }
2839
+ if (wrapper) {
2840
+ if (this.isCasting) (wrapper as HTMLElement).classList.add('uvf-casting');
2841
+ else (wrapper as HTMLElement).classList.remove('uvf-casting');
2842
+ }
2843
+ }
2844
+
2845
+ private _updateCastActiveTracks(): void {
2846
+ try {
2847
+ const castNs = (window as any).cast;
2848
+ if (!castNs || !castNs.framework) return;
2849
+ const session = castNs.framework.CastContext.getInstance().getCurrentSession();
2850
+ if (!session) return;
2851
+ const media = session.getMediaSession && session.getMediaSession();
2852
+ if (!media) return;
2853
+ let ids: number[] = [];
2854
+ if (this.selectedSubtitleKey && this.selectedSubtitleKey !== 'off') {
2855
+ const tid = this._castTrackIdByKey ? this._castTrackIdByKey[this.selectedSubtitleKey] : null;
2856
+ if (tid) ids = [tid];
2857
+ }
2858
+ if (typeof media.setActiveTracks === 'function') {
2859
+ media.setActiveTracks(ids, () => {}, () => {});
2860
+ } else if (typeof media.setActiveTrackIds === 'function') {
2861
+ media.setActiveTrackIds(ids);
2862
+ }
2863
+ } catch (_) {}
2864
+ }
2865
+
2866
+ private onCastButtonClick(): void {
2867
+ try {
2868
+ const castNs = (window as any).cast;
2869
+ if (this.isCasting && castNs && castNs.framework) {
2870
+ const ctx = castNs.framework.CastContext.getInstance();
2871
+ ctx.requestSession().catch(() => {});
2872
+ return;
2873
+ }
2874
+ } catch (_) {}
2875
+ // Not casting yet
2876
+ this.initCast();
2877
+ }
2878
+
2879
+ private stopCasting(): void {
2880
+ try {
2881
+ const castNs = (window as any).cast;
2882
+ if (!castNs || !castNs.framework) { this.showNotification('Cast not ready'); return; }
2883
+ const ctx = castNs.framework.CastContext.getInstance();
2884
+ const sess = ctx.getCurrentSession && ctx.getCurrentSession();
2885
+ if (sess) {
2886
+ try { sess.endSession(true); } catch (_) {}
2887
+ this.disableCastRemoteControl();
2888
+ this.showNotification('Stopped casting');
2889
+ } else {
2890
+ this.showNotification('Not casting');
2891
+ }
2892
+ } catch (_) {
2893
+ // ignore
2894
+ } finally {
2895
+ this._syncCastButtons();
2896
+ }
2897
+ }
2898
+
2899
+ private async initCast(): Promise<void> {
2900
+ try {
2901
+ let castNs = (window as any).cast;
2902
+ if (!castNs || !castNs.framework) {
2903
+ await this.loadScript('https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1');
2904
+ // wait briefly for framework
2905
+ const start = Date.now();
2906
+ while ((!((window as any).cast && (window as any).cast.framework)) && Date.now() - start < 3000) {
2907
+ await new Promise(r => setTimeout(r, 100));
2908
+ }
2909
+ castNs = (window as any).cast;
2910
+ }
2911
+ if (!(castNs && castNs.framework)) {
2912
+ this.showNotification('Cast framework not ready');
2913
+ return;
2914
+ }
2915
+
2916
+ this.setupCastContext();
2917
+ const ctx = castNs.framework.CastContext.getInstance();
2918
+ await ctx.requestSession();
2919
+ const session = ctx.getCurrentSession();
2920
+ if (!session) { this.showNotification('No cast session'); return; }
2921
+
2922
+ const url = this.source?.url || this.video?.src || '';
2923
+ const u = (url || '').toLowerCase();
2924
+ const contentType = u.includes('.m3u8') ? 'application/x-mpegurl'
2925
+ : u.includes('.mpd') ? 'application/dash+xml'
2926
+ : u.includes('.webm') ? 'video/webm'
2927
+ : 'video/mp4';
2928
+
2929
+ const chromeNs = (window as any).chrome;
2930
+ const mediaInfo = new chromeNs.cast.media.MediaInfo(url, contentType);
2931
+ mediaInfo.streamType = chromeNs.cast.media.StreamType.BUFFERED;
2932
+ try {
2933
+ const md = new chromeNs.cast.media.GenericMediaMetadata();
2934
+ md.title = this.source?.metadata?.title || (this.video?.currentSrc ? this.video!.currentSrc.split('/').slice(-1)[0] : 'Web Player');
2935
+ mediaInfo.metadata = md;
2936
+ } catch (_) {}
2937
+
2938
+ // Subtitle tracks -> Cast tracks mapping
2939
+ const castTracks: any[] = [];
2940
+ this._castTrackIdByKey = {};
2941
+ const inferTextTrackContentType = (trackUrl: string) => {
2942
+ const lu = (trackUrl || '').toLowerCase();
2943
+ if (lu.endsWith('.vtt')) return 'text/vtt';
2944
+ if (lu.endsWith('.srt')) return 'application/x-subrip';
2945
+ if (lu.endsWith('.ttml') || lu.endsWith('.dfxp') || lu.endsWith('.xml')) return 'application/ttml+xml';
2946
+ return 'text/vtt';
2947
+ };
2948
+ if (Array.isArray(this.subtitles) && this.subtitles.length > 0) {
2949
+ let nextId = 1;
2950
+ for (let i = 0; i < this.subtitles.length; i++) {
2951
+ const t = this.subtitles[i];
2952
+ const key = t.label || t.language || `Track ${i+1}`;
2953
+ try {
2954
+ const track = new chromeNs.cast.media.Track(nextId, chromeNs.cast.media.TrackType.TEXT);
2955
+ track.trackContentId = t.url;
2956
+ track.trackContentType = inferTextTrackContentType(t.url);
2957
+ track.subtype = chromeNs.cast.media.TextTrackType.SUBTITLES;
2958
+ track.name = key;
2959
+ track.language = t.language || '';
2960
+ track.customData = null;
2961
+ castTracks.push(track);
2962
+ this._castTrackIdByKey[key] = nextId;
2963
+ nextId++;
2964
+ } catch (_) {}
2965
+ }
2966
+ }
2967
+ if (castTracks.length > 0) {
2968
+ mediaInfo.tracks = castTracks;
2969
+ try {
2970
+ const style = new chromeNs.cast.media.TextTrackStyle();
2971
+ style.backgroundColor = '#00000000';
2972
+ style.foregroundColor = '#FFFFFFFF';
2973
+ style.edgeType = chromeNs.cast.media.TextTrackEdgeType.DROP_SHADOW;
2974
+ style.edgeColor = '#000000FF';
2975
+ style.fontScale = 1.0;
2976
+ mediaInfo.textTrackStyle = style;
2977
+ } catch (_) {}
2978
+ }
2979
+
2980
+ const request = new chromeNs.cast.media.LoadRequest(mediaInfo);
2981
+ request.autoplay = true;
2982
+ try { request.currentTime = Math.max(0, Math.floor(this.video?.currentTime || 0)); } catch (_) {}
2983
+ // Determine selected subtitle key from currentSubtitleIndex
2984
+ const currentIdx = this.currentSubtitleIndex;
2985
+ this.selectedSubtitleKey = (currentIdx >= 0 && this.subtitles[currentIdx]) ? (this.subtitles[currentIdx].label || this.subtitles[currentIdx].language) : 'off';
2986
+ if (this.selectedSubtitleKey && this.selectedSubtitleKey !== 'off') {
2987
+ const tid = this._castTrackIdByKey[this.selectedSubtitleKey];
2988
+ if (tid) request.activeTrackIds = [tid];
2989
+ }
2990
+
2991
+ await session.loadMedia(request);
2992
+ this.enableCastRemoteControl();
2993
+ this.showNotification('Casting to device');
2994
+ } catch (err) {
2995
+ if (this.config.debug) console.error('[Cast] init cast failed:', err);
2996
+ this.showNotification('Cast failed');
2997
+ }
2998
+ }
2999
+
3000
+ private async shareVideo(): Promise<void> {
3001
+ const shareData: ShareData = { url: window.location.href };
3002
+ const t = (this.source?.metadata?.title || '').toString().trim();
3003
+ const d = (this.source?.metadata?.description || '').toString().trim();
3004
+ if (t) shareData.title = t;
3005
+ if (d) shareData.text = d;
3006
+
3007
+ try {
3008
+ if (navigator.share) {
3009
+ await navigator.share(shareData);
3010
+ } else {
3011
+ // Fallback: Copy to clipboard
3012
+ await navigator.clipboard.writeText(window.location.href);
3013
+ this.showNotification('Link copied to clipboard');
3014
+ }
3015
+ } catch (error) {
3016
+ console.error('Share failed:', error);
3017
+ this.showNotification('Share failed');
3018
+ }
3019
+ }
3020
+
3021
+ private updateMetadataUI(): void {
3022
+ try {
3023
+ const md = this.source?.metadata || ({} as any);
3024
+ const titleBar = (this.container?.querySelector('.uvf-title-bar') as HTMLElement) || null;
3025
+ const titleEl = document.getElementById('uvf-video-title') as HTMLElement | null;
3026
+ const descEl = document.getElementById('uvf-video-description') as HTMLElement | null;
3027
+ const thumbEl = document.getElementById('uvf-video-thumb') as HTMLImageElement | null;
3028
+
3029
+ const titleText = (md.title || '').toString().trim();
3030
+ const descText = (md.description || '').toString().trim();
3031
+ const thumbUrl = (md.thumbnailUrl || '').toString().trim();
3032
+
3033
+ // Title
3034
+ if (titleEl) {
3035
+ titleEl.textContent = titleText;
3036
+ titleEl.style.display = titleText ? 'block' : 'none';
3037
+ }
3038
+
3039
+ // Description
3040
+ if (descEl) {
3041
+ descEl.textContent = descText;
3042
+ descEl.style.display = descText ? 'block' : 'none';
3043
+ }
3044
+
3045
+ // Thumbnail
3046
+ if (thumbEl) {
3047
+ if (thumbUrl) {
3048
+ thumbEl.src = thumbUrl;
3049
+ thumbEl.style.display = 'block';
3050
+ } else {
3051
+ thumbEl.removeAttribute('src');
3052
+ thumbEl.style.display = 'none';
3053
+ }
3054
+ }
3055
+
3056
+ // Hide entire title bar if nothing to show
3057
+ const hasAny = !!(titleText || descText || thumbUrl);
3058
+ if (titleBar) {
3059
+ titleBar.style.display = hasAny ? '' : 'none';
3060
+ }
3061
+ } catch (_) { /* ignore */ }
3062
+ }
3063
+
3064
+ private showNotification(message: string): void {
3065
+ // Use the shortcut indicator for notifications
3066
+ this.showShortcutIndicator(message);
3067
+ }
3068
+
3069
+ private async cleanup(): Promise<void> {
3070
+ if (this.hls) {
3071
+ this.hls.destroy();
3072
+ this.hls = null;
3073
+ }
3074
+
3075
+ if (this.dash) {
3076
+ this.dash.reset();
3077
+ this.dash = null;
3078
+ }
3079
+
3080
+ this.qualities = [];
3081
+ this.currentQualityIndex = -1;
3082
+ this.autoQuality = true;
3083
+ }
3084
+
3085
+ async destroy(): Promise<void> {
3086
+ await this.cleanup();
3087
+
3088
+ // Clear timeouts
3089
+ if (this.hideControlsTimeout) {
3090
+ clearTimeout(this.hideControlsTimeout);
3091
+ }
3092
+ if (this.volumeHideTimeout) {
3093
+ clearTimeout(this.volumeHideTimeout);
3094
+ }
3095
+
3096
+ if (this.video) {
3097
+ this.video.pause();
3098
+ this.video.removeAttribute('src');
3099
+ this.video.load();
3100
+ this.video.remove();
3101
+ this.video = null;
3102
+ }
3103
+
3104
+ if (this.container) {
3105
+ this.container.innerHTML = '';
3106
+ }
3107
+
3108
+ this.events.removeAllListeners();
3109
+ }
3110
+ }