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,3556 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Enhanced Video Player - Premium UI</title>
7
+
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
17
+ background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
18
+ min-height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ overflow-x: hidden;
24
+ }
25
+
26
+ /* Main Player Container */
27
+ .player-wrapper {
28
+ width: 100%;
29
+ max-width: 100%;
30
+ background: #000;
31
+ position: relative;
32
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
33
+ overflow: hidden;
34
+ /* Theme variables */
35
+ --uvf-accent-1: #ff0000;
36
+ --uvf-accent-2: #ff4d4f;
37
+ --uvf-accent-1-20: rgba(255,0,0,0.2);
38
+ --uvf-icon-color: #ffffff;
39
+ --uvf-text-primary: #ffffff;
40
+ --uvf-text-secondary: rgba(255,255,255,0.7);
41
+ --uvf-scrollbar-thumb-start: rgba(255,0,0,0.30);
42
+ --uvf-scrollbar-thumb-end: rgba(255,0,0,0.38);
43
+ --uvf-scrollbar-thumb-hover-start: rgba(255,0,0,0.42);
44
+ --uvf-scrollbar-thumb-hover-end: rgba(255,0,0,0.52);
45
+ }
46
+
47
+ .player-wrapper::before {
48
+ content: '';
49
+ position: absolute;
50
+ top: -2px;
51
+ left: -2px;
52
+ right: -2px;
53
+ bottom: -2px;
54
+ background: linear-gradient(45deg, var(--uvf-accent-1), var(--uvf-accent-2), var(--uvf-accent-1));
55
+ background-size: 400% 400%;
56
+ animation: gradientBorder 10s ease infinite;
57
+ z-index: -1;
58
+ opacity: 0;
59
+ transition: opacity 0.3s ease;
60
+ }
61
+
62
+ .player-wrapper:hover::before {
63
+ opacity: 0.3;
64
+ }
65
+
66
+ @keyframes gradientBorder {
67
+ 0% { background-position: 0% 50%; }
68
+ 50% { background-position: 100% 50%; }
69
+ 100% { background-position: 0% 50%; }
70
+ }
71
+
72
+ .video-container {
73
+ position: relative;
74
+ width: 100%;
75
+ aspect-ratio: 16 / 9;
76
+ height: auto;
77
+ max-height: 100vh;
78
+ background: radial-gradient(ellipse at center, #1a1a2e 0%, #000 100%);
79
+ overflow: hidden;
80
+ }
81
+
82
+ #videoPlayer {
83
+ position: absolute;
84
+ top: 0;
85
+ left: 0;
86
+ width: 100%;
87
+ height: 100%;
88
+ background: #000;
89
+ object-fit: contain;
90
+ }
91
+
92
+ /* Enhanced Watermark */
93
+ .watermark-layer {
94
+ position: absolute;
95
+ top: 0;
96
+ left: 0;
97
+ width: 100%;
98
+ height: 100%;
99
+ pointer-events: none;
100
+ z-index: 5;
101
+ mix-blend-mode: screen;
102
+ }
103
+
104
+ /* Top Gradient Overlay */
105
+ .top-gradient {
106
+ position: absolute;
107
+ top: 0;
108
+ left: 0;
109
+ right: 0;
110
+ height: 120px;
111
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
112
+ z-index: 6;
113
+ opacity: 0;
114
+ transition: opacity 0.3s ease;
115
+ }
116
+
117
+ .player-wrapper:hover .top-gradient {
118
+ opacity: 1;
119
+ }
120
+
121
+ /* Title Bar */
122
+ .title-bar {
123
+ position: absolute;
124
+ top: 0;
125
+ left: 0;
126
+ right: 0;
127
+ padding: 20px;
128
+ z-index: 7;
129
+ opacity: 0;
130
+ transform: translateY(-10px);
131
+ transition: all 0.3s ease;
132
+ }
133
+
134
+ .player-wrapper:hover .title-bar {
135
+ opacity: 1;
136
+ transform: translateY(0);
137
+ }
138
+
139
+ .video-title {
140
+ color: var(--uvf-text-primary);
141
+ font-size: 18px;
142
+ font-weight: 600;
143
+ text-shadow: 0 2px 4px rgba(0,0,0,0.5);
144
+ }
145
+
146
+ .video-subtitle {
147
+ color: var(--uvf-text-secondary);
148
+ font-size: 14px;
149
+ margin-top: 5px;
150
+ }
151
+
152
+ /* Enhanced Cast Button */
153
+ .top-controls {
154
+ position: absolute;
155
+ top: 20px;
156
+ right: 20px;
157
+ z-index: 10;
158
+ display: flex;
159
+ gap: 10px;
160
+ opacity: 0;
161
+ transform: translateY(-10px);
162
+ transition: all 0.3s ease;
163
+ }
164
+
165
+ .player-wrapper:hover .top-controls {
166
+ opacity: 1;
167
+ transform: translateY(0);
168
+ }
169
+
170
+ .top-btn {
171
+ width: 40px;
172
+ height: 40px;
173
+ background: rgba(255,255,255,0.1);
174
+ backdrop-filter: blur(10px);
175
+ border: 1px solid rgba(255,255,255,0.2);
176
+ border-radius: 50%;
177
+ cursor: pointer;
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ transition: all 0.3s ease;
182
+ }
183
+
184
+ .top-btn:hover {
185
+ background: rgba(255,255,255,0.2);
186
+ transform: scale(1.1);
187
+ box-shadow: 0 0 20px rgba(255,255,255,0.3);
188
+ }
189
+
190
+ .top-btn.cast-grey {
191
+ opacity: 0.6;
192
+ filter: grayscale(0.6);
193
+ box-shadow: none;
194
+ background: rgba(255,255,255,0.08);
195
+ }
196
+ .top-btn.cast-grey:hover {
197
+ transform: none;
198
+ background: rgba(255,255,255,0.12);
199
+ box-shadow: none;
200
+ }
201
+
202
+ .top-btn svg {
203
+ width: 20px;
204
+ height: 20px;
205
+ fill: var(--uvf-icon-color);
206
+ }
207
+
208
+ /* Pill-style button for prominent actions */
209
+ .pill-btn {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: 8px;
213
+ height: 40px;
214
+ padding: 0 14px;
215
+ border-radius: 999px;
216
+ border: 1px solid rgba(255,255,255,0.25);
217
+ background: rgba(255,255,255,0.08);
218
+ color: #fff;
219
+ cursor: pointer;
220
+ transition: all 0.2s ease;
221
+ box-shadow: 0 4px 14px rgba(0,0,0,0.4);
222
+ }
223
+ .pill-btn:hover {
224
+ transform: translateY(-1px);
225
+ background: rgba(255,255,255,0.15);
226
+ box-shadow: 0 6px 18px rgba(0,0,0,0.5);
227
+ }
228
+ .pill-btn:active {
229
+ transform: translateY(0);
230
+ }
231
+ .pill-btn svg {
232
+ width: 18px;
233
+ height: 18px;
234
+ fill: currentColor;
235
+ }
236
+ .pill-btn span {
237
+ font-size: 13px;
238
+ font-weight: 600;
239
+ letter-spacing: 0.2px;
240
+ }
241
+ .stop-cast-btn {
242
+ background: linear-gradient(135deg, #ff4d4f, #d9363e);
243
+ border: 1px solid rgba(255, 77, 79, 0.6);
244
+ box-shadow: 0 0 20px rgba(255, 77, 79, 0.35);
245
+ }
246
+ .stop-cast-btn:hover {
247
+ background: linear-gradient(135deg, #ff6b6d, #f0444b);
248
+ box-shadow: 0 0 26px rgba(255, 77, 79, 0.5);
249
+ }
250
+
251
+ /* Enhanced Loading Spinner */
252
+ .loading-container {
253
+ position: absolute;
254
+ top: 50%;
255
+ left: 50%;
256
+ transform: translate(-50%, -50%);
257
+ z-index: 10;
258
+ display: none;
259
+ }
260
+
261
+ .loading-container.active {
262
+ display: block;
263
+ }
264
+
265
+ .loading-spinner {
266
+ width: 60px;
267
+ height: 60px;
268
+ position: relative;
269
+ }
270
+
271
+ .loading-spinner::before,
272
+ .loading-spinner::after {
273
+ content: '';
274
+ position: absolute;
275
+ top: 0;
276
+ left: 0;
277
+ width: 100%;
278
+ height: 100%;
279
+ border-radius: 50%;
280
+ border: 3px solid transparent;
281
+ }
282
+
283
+ .loading-spinner::before {
284
+ border-top-color: var(--uvf-accent-1);
285
+ animation: spin 1s linear infinite;
286
+ }
287
+
288
+ .loading-spinner::after {
289
+ border-bottom-color: var(--uvf-accent-2);
290
+ animation: spin 1s linear infinite reverse;
291
+ }
292
+
293
+ @keyframes spin {
294
+ to { transform: rotate(360deg); }
295
+ }
296
+
297
+ .loading-text {
298
+ color: #fff;
299
+ font-size: 12px;
300
+ text-align: center;
301
+ margin-top: 70px;
302
+ text-transform: uppercase;
303
+ letter-spacing: 2px;
304
+ }
305
+
306
+ /* Enhanced Center Play Button */
307
+ .center-play-btn {
308
+ position: absolute;
309
+ top: 50%;
310
+ left: 50%;
311
+ transform: translate(-50%, -50%);
312
+ width: 80px;
313
+ height: 80px;
314
+ background: rgba(255,255,255,0.1);
315
+ backdrop-filter: blur(10px);
316
+ border: 2px solid rgba(255,255,255,0.3);
317
+ border-radius: 50%;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: center;
321
+ cursor: pointer;
322
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
323
+ z-index: 8;
324
+ }
325
+
326
+ .center-play-btn::before {
327
+ content: '';
328
+ position: absolute;
329
+ top: -20px;
330
+ left: -20px;
331
+ right: -20px;
332
+ bottom: -20px;
333
+ background: radial-gradient(circle, rgba(255,255,255,0.1), transparent);
334
+ border-radius: 50%;
335
+ opacity: 0;
336
+ transition: opacity 0.3s ease;
337
+ }
338
+
339
+ .center-play-btn:hover {
340
+ transform: translate(-50%, -50%) scale(1.1);
341
+ background: rgba(255,255,255,0.2);
342
+ box-shadow: 0 0 40px rgba(255,255,255,0.4);
343
+ }
344
+
345
+ .center-play-btn:hover::before {
346
+ opacity: 1;
347
+ animation: pulse 1.5s infinite;
348
+ }
349
+
350
+ @keyframes pulse {
351
+ 0%, 100% { transform: scale(1); opacity: 0.5; }
352
+ 50% { transform: scale(1.2); opacity: 0; }
353
+ }
354
+
355
+ .center-play-btn.hidden {
356
+ opacity: 0;
357
+ transform: translate(-50%, -50%) scale(0.8);
358
+ pointer-events: none;
359
+ }
360
+
361
+ .center-play-btn svg {
362
+ width: 35px;
363
+ height: 35px;
364
+ fill: var(--uvf-icon-color);
365
+ margin-left: 4px;
366
+ filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
367
+ }
368
+
369
+ /* Enhanced Controls Bar */
370
+ .controls-gradient {
371
+ position: absolute;
372
+ bottom: 0;
373
+ left: 0;
374
+ right: 0;
375
+ height: 150px;
376
+ background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
377
+ z-index: 9;
378
+ pointer-events: none;
379
+ opacity: 0;
380
+ transition: opacity 0.3s ease;
381
+ }
382
+
383
+ .player-wrapper:hover .controls-gradient {
384
+ opacity: 1;
385
+ }
386
+
387
+ .controls-bar {
388
+ position: absolute;
389
+ bottom: 0;
390
+ left: 0;
391
+ right: 0;
392
+ padding: 20px;
393
+ z-index: 10;
394
+ opacity: 0;
395
+ transform: translateY(10px);
396
+ transition: all 0.3s ease;
397
+ }
398
+
399
+ .player-wrapper:hover .controls-bar {
400
+ opacity: 1;
401
+ transform: translateY(0);
402
+ }
403
+
404
+ /* Progress Bar Container */
405
+ .progress-section {
406
+ margin-bottom: 15px;
407
+ }
408
+
409
+ .progress-bar-wrapper {
410
+ position: relative;
411
+ width: 100%;
412
+ height: 6px;
413
+ background: rgba(255,255,255,0.1);
414
+ border-radius: 3px;
415
+ cursor: pointer;
416
+ overflow: visible;
417
+ transition: transform 0.2s ease;
418
+ }
419
+
420
+ .progress-bar-wrapper:hover {
421
+ transform: scaleY(1.5);
422
+ }
423
+
424
+ .progress-buffered {
425
+ position: absolute;
426
+ top: 0;
427
+ left: 0;
428
+ height: 100%;
429
+ background: rgba(255,255,255,0.2);
430
+ border-radius: 3px;
431
+ pointer-events: none;
432
+ }
433
+
434
+ .progress-filled {
435
+ position: absolute;
436
+ top: 0;
437
+ left: 0;
438
+ height: 100%;
439
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
440
+ border-radius: 3px;
441
+ pointer-events: none;
442
+ box-shadow: 0 0 10px rgba(255,0,0,0.35);
443
+ }
444
+
445
+ .progress-handle {
446
+ position: absolute;
447
+ top: 50%;
448
+ transform: translate(-50%, -50%) scale(0);
449
+ width: 16px;
450
+ height: 16px;
451
+ background: #fff;
452
+ border-radius: 50%;
453
+ box-shadow: 0 0 15px rgba(255,255,255,0.5);
454
+ transition: transform 0.2s ease;
455
+ pointer-events: none;
456
+ }
457
+
458
+ .progress-bar-wrapper:hover .progress-handle {
459
+ transform: translate(-50%, -50%) scale(1);
460
+ }
461
+
462
+ /* Time Tooltip */
463
+ .time-tooltip {
464
+ position: absolute;
465
+ bottom: 20px;
466
+ background: rgba(0,0,0,0.9);
467
+ color: #fff;
468
+ padding: 5px 10px;
469
+ border-radius: 4px;
470
+ font-size: 12px;
471
+ pointer-events: none;
472
+ opacity: 0;
473
+ transform: translateX(-50%);
474
+ transition: opacity 0.2s ease;
475
+ }
476
+
477
+ .progress-bar-wrapper:hover .time-tooltip {
478
+ opacity: 1;
479
+ }
480
+
481
+ /* Controls Row */
482
+ .controls-row {
483
+ display: flex;
484
+ align-items: center;
485
+ gap: 15px;
486
+ }
487
+
488
+ /* Enhanced Control Buttons */
489
+ .control-btn {
490
+ background: rgba(255,255,255,0.1);
491
+ backdrop-filter: blur(10px);
492
+ border: none;
493
+ width: 40px;
494
+ height: 40px;
495
+ border-radius: 50%;
496
+ color: #fff;
497
+ cursor: pointer;
498
+ display: flex;
499
+ align-items: center;
500
+ justify-content: center;
501
+ transition: all 0.3s ease;
502
+ position: relative;
503
+ overflow: hidden;
504
+ }
505
+
506
+ .control-btn::before {
507
+ content: '';
508
+ position: absolute;
509
+ top: 50%;
510
+ left: 50%;
511
+ width: 0;
512
+ height: 0;
513
+ background: radial-gradient(circle, rgba(255,255,255,0.3), transparent);
514
+ transform: translate(-50%, -50%);
515
+ transition: width 0.3s, height 0.3s;
516
+ }
517
+
518
+ .control-btn:hover {
519
+ background: rgba(255,255,255,0.2);
520
+ transform: scale(1.1);
521
+ }
522
+
523
+ .control-btn:hover::before {
524
+ width: 100%;
525
+ height: 100%;
526
+ }
527
+
528
+ .control-btn:active {
529
+ transform: scale(0.95);
530
+ }
531
+
532
+ .control-btn svg {
533
+ width: 20px;
534
+ height: 20px;
535
+ fill: var(--uvf-icon-color);
536
+ z-index: 1;
537
+ position: relative;
538
+ }
539
+
540
+ .control-btn.play-pause {
541
+ width: 50px;
542
+ height: 50px;
543
+ background: linear-gradient(135deg, var(--uvf-accent-1), var(--uvf-accent-2));
544
+ }
545
+
546
+ .control-btn.play-pause svg {
547
+ width: 24px;
548
+ height: 24px;
549
+ }
550
+
551
+ /* Time Display */
552
+ .time-display {
553
+ color: var(--uvf-text-primary);
554
+ font-size: 14px;
555
+ font-weight: 500;
556
+ min-width: 120px;
557
+ padding: 0 10px;
558
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
559
+ }
560
+
561
+ /* Volume Control */
562
+ .volume-control {
563
+ display: flex;
564
+ align-items: center;
565
+ position: relative;
566
+ }
567
+
568
+ .volume-panel {
569
+ position: absolute;
570
+ left: 40px;
571
+ top: 50%;
572
+ transform: translateY(-50%);
573
+ display: flex;
574
+ align-items: center;
575
+ background: rgba(0,0,0,0.95);
576
+ backdrop-filter: blur(15px);
577
+ border: 1px solid rgba(255,255,255,0.2);
578
+ border-radius: 20px;
579
+ padding: 10px 15px;
580
+ opacity: 0;
581
+ visibility: hidden;
582
+ pointer-events: none;
583
+ transition: opacity 0.2s ease, visibility 0.2s ease, left 0.3s ease;
584
+ z-index: 100;
585
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
586
+ }
587
+
588
+ .volume-control:hover .volume-panel,
589
+ .volume-panel:hover,
590
+ .volume-panel.active {
591
+ opacity: 1;
592
+ visibility: visible;
593
+ pointer-events: all;
594
+ left: 50px;
595
+ }
596
+
597
+ .volume-slider {
598
+ width: 120px;
599
+ height: 8px;
600
+ background: rgba(255,255,255,0.2);
601
+ border-radius: 4px;
602
+ cursor: pointer;
603
+ position: relative;
604
+ margin: 0 10px;
605
+ overflow: visible;
606
+ display: block;
607
+ }
608
+
609
+ .volume-slider:hover {
610
+ background: rgba(255,255,255,0.3);
611
+ }
612
+
613
+ .volume-fill {
614
+ height: 100%;
615
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
616
+ border-radius: 4px;
617
+ pointer-events: none;
618
+ transition: width 0.1s ease;
619
+ position: absolute;
620
+ top: 0;
621
+ left: 0;
622
+ }
623
+
624
+ .volume-handle {
625
+ position: absolute;
626
+ right: -8px;
627
+ top: 50%;
628
+ transform: translateY(-50%);
629
+ width: 16px;
630
+ height: 16px;
631
+ background: #fff;
632
+ border-radius: 50%;
633
+ box-shadow: 0 0 10px rgba(255,255,255,0.5);
634
+ pointer-events: none;
635
+ opacity: 0;
636
+ transition: opacity 0.2s ease;
637
+ }
638
+
639
+ .volume-slider:hover .volume-handle,
640
+ .volume-panel.active .volume-handle {
641
+ opacity: 1;
642
+ }
643
+
644
+ .volume-value {
645
+ color: var(--uvf-text-primary);
646
+ font-size: 12px;
647
+ min-width: 30px;
648
+ text-align: center;
649
+ }
650
+
651
+ /* Right Controls */
652
+ .right-controls {
653
+ margin-left: auto;
654
+ display: flex;
655
+ align-items: center;
656
+ gap: 10px;
657
+ }
658
+
659
+ /* Settings Button */
660
+ .settings-btn {
661
+ position: relative;
662
+ }
663
+
664
+ .settings-menu {
665
+ position: absolute;
666
+ bottom: 50px;
667
+ right: 0;
668
+ background: rgba(0,0,0,0.95);
669
+ backdrop-filter: blur(20px);
670
+ border: 1px solid rgba(255,255,255,0.1);
671
+ border-radius: 12px;
672
+ padding: 10px 0;
673
+ min-width: 200px;
674
+ max-height: 60vh;
675
+ overflow-y: auto;
676
+ -webkit-overflow-scrolling: touch;
677
+ overscroll-behavior: contain;
678
+ /* Firefox scrollbar (compact) */
679
+ scrollbar-width: thin;
680
+ scrollbar-color: rgba(255,255,255,0.20) transparent;
681
+ /* Avoid layout shift when scrollbar appears */
682
+ scrollbar-gutter: stable both-edges;
683
+ /* Give a little breathing room on the right of content */
684
+ padding-right: 6px;
685
+ opacity: 0;
686
+ visibility: hidden;
687
+ transform: translateY(10px);
688
+ transition: all 0.3s ease;
689
+ }
690
+
691
+ /* WebKit-based browsers (Chrome, Edge, Safari) scrollbar */
692
+ .settings-menu::-webkit-scrollbar {
693
+ width: 6px; /* compact */
694
+ }
695
+ .settings-menu::-webkit-scrollbar-track {
696
+ background: transparent;
697
+ }
698
+ .settings-menu::-webkit-scrollbar-thumb {
699
+ background: linear-gradient(180deg, var(--uvf-scrollbar-thumb-start), var(--uvf-scrollbar-thumb-end));
700
+ border-radius: 8px;
701
+ }
702
+ .settings-menu::-webkit-scrollbar-thumb:hover {
703
+ background: linear-gradient(180deg, var(--uvf-scrollbar-thumb-hover-start), var(--uvf-scrollbar-thumb-hover-end));
704
+ }
705
+ .settings-menu::-webkit-scrollbar-corner {
706
+ background: transparent;
707
+ }
708
+
709
+ .settings-menu.active {
710
+ opacity: 1;
711
+ visibility: visible;
712
+ transform: translateY(0);
713
+ }
714
+
715
+ .settings-group {
716
+ padding: 10px 0;
717
+ border-bottom: 1px solid rgba(255,255,255,0.1);
718
+ }
719
+
720
+ .settings-group:last-child {
721
+ border-bottom: none;
722
+ }
723
+
724
+ .settings-label {
725
+ color: rgba(255,255,255,0.5);
726
+ font-size: 11px;
727
+ text-transform: uppercase;
728
+ letter-spacing: 1px;
729
+ padding: 0 15px 5px;
730
+ }
731
+
732
+ .settings-option {
733
+ color: #fff;
734
+ font-size: 14px;
735
+ padding: 8px 15px;
736
+ cursor: pointer;
737
+ transition: all 0.2s ease;
738
+ display: flex;
739
+ justify-content: space-between;
740
+ align-items: center;
741
+ }
742
+
743
+ .settings-option:hover {
744
+ background: rgba(255,255,255,0.1);
745
+ padding-left: 20px;
746
+ }
747
+
748
+ .settings-option.active {
749
+ color: var(--uvf-accent-2);
750
+ }
751
+
752
+ .settings-option.active::after {
753
+ content: '✓';
754
+ margin-left: 10px;
755
+ }
756
+
757
+ /* Quality Badge */
758
+ .quality-badge {
759
+ background: var(--uvf-accent-1-20);
760
+ border: 1px solid var(--uvf-accent-1);
761
+ color: var(--uvf-accent-1);
762
+ font-size: 11px;
763
+ font-weight: 600;
764
+ padding: 4px 8px;
765
+ border-radius: 4px;
766
+ text-transform: uppercase;
767
+ }
768
+
769
+ /* Fullscreen Button */
770
+ .fullscreen-btn svg {
771
+ width: 18px;
772
+ height: 18px;
773
+ }
774
+
775
+ /* Fullscreen layout */
776
+ .player-wrapper:fullscreen,
777
+ .player-wrapper:-webkit-full-screen,
778
+ .player-wrapper:-ms-fullscreen {
779
+ width: 100vw !important;
780
+ height: 100vh !important;
781
+ max-width: 100vw !important;
782
+ }
783
+ .player-wrapper:fullscreen .video-container,
784
+ .player-wrapper:-webkit-full-screen .video-container,
785
+ .player-wrapper:-ms-fullscreen .video-container {
786
+ width: 100% !important;
787
+ height: 100% !important;
788
+ max-height: 100% !important;
789
+ aspect-ratio: auto !important;
790
+ }
791
+ .player-wrapper:fullscreen #videoPlayer,
792
+ .player-wrapper:-webkit-full-screen #videoPlayer,
793
+ .player-wrapper:-ms-fullscreen #videoPlayer {
794
+ width: 100% !important;
795
+ height: 100% !important;
796
+ object-fit: contain !important;
797
+ background: #000;
798
+ }
799
+
800
+ /* Hover to show controls */
801
+ .player-wrapper:hover .controls-bar,
802
+ .player-wrapper:hover .controls-gradient,
803
+ .player-wrapper:hover .top-gradient,
804
+ .player-wrapper:hover .title-bar,
805
+ .player-wrapper:hover .top-controls,
806
+ .player-wrapper.controls-visible .controls-bar,
807
+ .player-wrapper.controls-visible .controls-gradient,
808
+ .player-wrapper.controls-visible .top-gradient,
809
+ .player-wrapper.controls-visible .title-bar,
810
+ .player-wrapper.controls-visible .top-controls {
811
+ opacity: 1;
812
+ transform: translateY(0);
813
+ }
814
+ /* While casting, keep top controls visible for clarity */
815
+ .player-wrapper.casting .top-controls {
816
+ opacity: 1;
817
+ transform: translateY(0);
818
+ }
819
+
820
+ .player-wrapper.no-cursor {
821
+ cursor: none;
822
+ }
823
+
824
+ .player-wrapper.no-cursor .controls-bar,
825
+ .player-wrapper.no-cursor .controls-gradient,
826
+ .player-wrapper.no-cursor .top-gradient,
827
+ .player-wrapper.no-cursor .title-bar,
828
+ .player-wrapper.no-cursor .top-controls {
829
+ opacity: 0 !important;
830
+ transform: translateY(10px) !important;
831
+ pointer-events: none;
832
+ }
833
+
834
+ /* Keyboard Shortcut Indicator */
835
+ .shortcut-indicator {
836
+ position: absolute;
837
+ top: 50%;
838
+ left: 50%;
839
+ transform: translate(-50%, -50%);
840
+ background: rgba(0,0,0,0.8);
841
+ color: #fff;
842
+ padding: 20px 30px;
843
+ border-radius: 8px;
844
+ font-size: 24px;
845
+ font-weight: 600;
846
+ opacity: 0;
847
+ pointer-events: none;
848
+ z-index: 20;
849
+ transition: opacity 0.3s ease;
850
+ }
851
+
852
+ .shortcut-indicator.active {
853
+ animation: fadeInOut 1s ease;
854
+ }
855
+
856
+ /* Key action overlay styles (YouTube-like) */
857
+ .shortcut-indicator.ki-icon {
858
+ background: transparent;
859
+ padding: 0;
860
+ border-radius: 50%;
861
+ }
862
+ .shortcut-indicator .ki {
863
+ display: inline-flex;
864
+ align-items: center;
865
+ justify-content: center;
866
+ gap: 10px;
867
+ color: var(--uvf-icon-color);
868
+ }
869
+ .shortcut-indicator .ki svg {
870
+ width: 72px;
871
+ height: 72px;
872
+ fill: var(--uvf-icon-color);
873
+ filter: drop-shadow(0 2px 6px rgba(0,0,0,0.45));
874
+ }
875
+ .shortcut-indicator .ki-skip {
876
+ position: relative;
877
+ width: 110px;
878
+ height: 110px;
879
+ }
880
+ .shortcut-indicator .ki-skip svg {
881
+ width: 110px;
882
+ height: 110px;
883
+ position: relative;
884
+ z-index: 1;
885
+ }
886
+ .shortcut-indicator .ki-skip .ki-skip-num {
887
+ position: absolute;
888
+ top: 52%;
889
+ left: 50%;
890
+ transform: translate(-50%, -50%);
891
+ color: var(--uvf-text-primary);
892
+ font-weight: 800;
893
+ font-size: 22px;
894
+ text-shadow: 0 2px 6px rgba(0,0,0,0.5);
895
+ pointer-events: none;
896
+ z-index: 2; /* ensure number sits above the arrow */
897
+ }
898
+ .shortcut-indicator .ki-volume {
899
+ align-items: center;
900
+ }
901
+ .shortcut-indicator .ki-vol-icon svg {
902
+ width: 36px;
903
+ height: 36px;
904
+ }
905
+ .shortcut-indicator .ki-vol-bar {
906
+ width: 180px;
907
+ height: 8px;
908
+ background: rgba(255,255,255,0.25);
909
+ border-radius: 4px;
910
+ overflow: hidden;
911
+ }
912
+ .shortcut-indicator .ki-vol-fill {
913
+ height: 100%;
914
+ background: linear-gradient(90deg, var(--uvf-accent-1), var(--uvf-accent-2));
915
+ }
916
+ .shortcut-indicator .ki-vol-text {
917
+ font-size: 16px;
918
+ font-weight: 600;
919
+ color: var(--uvf-text-primary);
920
+ min-width: 42px;
921
+ text-align: right;
922
+ }
923
+ .shortcut-indicator .ki-text {
924
+ font-size: 18px;
925
+ color: var(--uvf-text-primary);
926
+ }
927
+
928
+ @keyframes fadeInOut {
929
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
930
+ 20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
931
+ 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
932
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
933
+ }
934
+
935
+ /* Mobile Responsiveness */
936
+ @media (max-width: 768px) {
937
+ .settings-btn,
938
+ .quality-badge {
939
+ display: none;
940
+ }
941
+
942
+ .control-btn {
943
+ width: 35px;
944
+ height: 35px;
945
+ }
946
+
947
+ .control-btn.play-pause {
948
+ width: 45px;
949
+ height: 45px;
950
+ }
951
+
952
+ .time-display {
953
+ font-size: 12px;
954
+ min-width: 100px;
955
+ }
956
+ }
957
+
958
+ /* Paywall Modal */
959
+ .paywall-overlay {
960
+ position: absolute;
961
+ inset: 0;
962
+ background: rgba(0,0,0,0.85);
963
+ z-index: 50;
964
+ display: none;
965
+ align-items: center;
966
+ justify-content: center;
967
+ }
968
+ .paywall-overlay.active { display: flex; }
969
+ .paywall-modal {
970
+ width: 80vw;
971
+ height: 80vh;
972
+ max-width: 1100px;
973
+ max-height: 800px;
974
+ background: #0f0f10;
975
+ border: 1px solid rgba(255,255,255,0.15);
976
+ border-radius: 12px;
977
+ display: flex;
978
+ flex-direction: column;
979
+ overflow: hidden;
980
+ box-shadow: 0 20px 60px rgba(0,0,0,0.7);
981
+ }
982
+ .paywall-header {
983
+ display: flex;
984
+ align-items: center;
985
+ gap: 16px;
986
+ padding: 16px 20px;
987
+ border-bottom: 1px solid rgba(255,255,255,0.1);
988
+ }
989
+ .paywall-thumb img {
990
+ width: 140px;
991
+ height: 80px;
992
+ object-fit: cover;
993
+ border-radius: 8px;
994
+ }
995
+ .paywall-title { color: #fff; font-size: 18px; font-weight: 700; }
996
+ .paywall-desc { color: rgba(255,255,255,0.75); font-size: 14px; margin-top: 4px; }
997
+ .paywall-content {
998
+ flex: 1;
999
+ display: flex;
1000
+ align-items: center;
1001
+ justify-content: center;
1002
+ padding: 20px;
1003
+ }
1004
+ .paywall-step { display: none; width: 100%; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
1005
+ .paywall-step.active { display: flex; }
1006
+ .paywall-message { color: #fff; font-size: 16px; }
1007
+ .paywall-actions { display: flex; gap: 12px; justify-content: center; padding: 12px 0; }
1008
+ .btn-primary { background: linear-gradient(135deg, #ff4d4f, #d9363e); color: #fff; border: 1px solid rgba(255,77,79,0.6); border-radius: 999px; padding: 10px 18px; cursor: pointer; }
1009
+ .btn-secondary { background: rgba(255,255,255,0.08); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 999px; padding: 10px 18px; cursor: pointer; }
1010
+ .gateway-list { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
1011
+ .gateway-btn { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; padding: 12px 16px; cursor: pointer; min-width: 120px; }
1012
+ .paywall-iframe { width: 100%; height: 100%; border: none; background: #fff; border-radius: 8px; }
1013
+
1014
+ </style>
1015
+
1016
+ <!-- Google Cast Sender SDK -->
1017
+ <script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
1018
+ </head>
1019
+ <body>
1020
+ <div class="player-wrapper" id="playerWrapper">
1021
+ <div class="video-container">
1022
+ <video id="videoPlayer"></video>
1023
+
1024
+ <!-- Watermark Canvas -->
1025
+ <canvas class="watermark-layer" id="watermarkCanvas"></canvas>
1026
+
1027
+ <!-- Top Gradient -->
1028
+ <div class="top-gradient"></div>
1029
+
1030
+ <!-- Title Bar -->
1031
+ <div class="title-bar" id="titleBar" style="display:none;">
1032
+ <div class="video-title" id="videoTitle" style="display:none;"></div>
1033
+ <div class="video-subtitle" id="videoDescription" style="display:none;"></div>
1034
+ </div>
1035
+
1036
+ <!-- Top Controls -->
1037
+ <div class="top-controls">
1038
+ <div class="top-btn" id="castBtn" title="Cast">
1039
+ <svg viewBox="0 0 24 24">
1040
+ <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"/>
1041
+ </svg>
1042
+ </div>
1043
+ <button class="pill-btn stop-cast-btn" id="stopCastBtn" title="Stop Casting" aria-label="Stop Casting" style="display: none;">
1044
+ <svg viewBox="0 0 24 24" aria-hidden="true">
1045
+ <path d="M6 6h12v12H6z"/>
1046
+ </svg>
1047
+ <span>Stop Casting</span>
1048
+ </button>
1049
+ <button class="pill-btn stop-cast-btn" id="rentOpenBtn" title="Rent to Unlock" aria-label="Rent" style="display: none;">
1050
+ <svg viewBox="0 0 24 24" aria-hidden="true">
1051
+ <path d="M12 2L3.5 20.29c-.18.37-.17.81.04 1.16.21.35.59.55 1 .55h14.92c.41 0 .79-.21 1-.55.21-.35.23-.79.04-1.16L12 2zm0 4.84L17.53 18H6.47L12 6.84zM11 10h2v4h-2v-4zm0 6h2v2h-2v-2z"/>
1052
+ </svg>
1053
+ <span>Rent Now</span>
1054
+ </button>
1055
+ <div class="top-btn" id="playlistBtn" title="Add to Playlist">
1056
+ <svg viewBox="0 0 24 24">
1057
+ <path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
1058
+ </svg>
1059
+ </div>
1060
+ <div class="top-btn" id="shareBtn" title="Share">
1061
+ <svg viewBox="0 0 24 24">
1062
+ <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"/>
1063
+ </svg>
1064
+ </div>
1065
+ </div>
1066
+
1067
+ <!-- Loading Spinner -->
1068
+ <div class="loading-container" id="loadingContainer">
1069
+ <div class="loading-spinner"></div>
1070
+ <div class="loading-text">Loading</div>
1071
+ </div>
1072
+
1073
+ <!-- Center Play Button -->
1074
+ <div class="center-play-btn" id="centerPlayBtn">
1075
+ <svg viewBox="0 0 24 24">
1076
+ <path d="M8 5v14l11-7z"/>
1077
+ </svg>
1078
+ </div>
1079
+
1080
+ <!-- Shortcut Indicator -->
1081
+ <div class="shortcut-indicator" id="shortcutIndicator"></div>
1082
+
1083
+ <!-- Paywall Overlay -->
1084
+ <div class="paywall-overlay" id="paywallOverlay" aria-modal="true" role="dialog" style="display:none;">
1085
+ <div class="paywall-modal">
1086
+ <div class="paywall-header">
1087
+ <div class="paywall-thumb"><img id="paywallThumb" alt="Thumbnail" /></div>
1088
+ <div>
1089
+ <div class="paywall-title" id="paywallTitle">Continue watching</div>
1090
+ <div class="paywall-desc" id="paywallDesc">Rent to continue watching this video.</div>
1091
+ </div>
1092
+ </div>
1093
+ <div class="paywall-content">
1094
+ <div class="paywall-step active" id="paywallStepIntro">
1095
+ <div class="paywall-message">Free preview ended. Rent to continue watching.</div>
1096
+ <div class="paywall-actions">
1097
+ <button class="btn-primary" id="rentNowBtn">Rent Now</button>
1098
+ </div>
1099
+ </div>
1100
+ <div class="paywall-step" id="paywallStepGateway">
1101
+ <div class="paywall-message">Choose a payment method</div>
1102
+ <div class="gateway-list">
1103
+ <button class="gateway-btn" id="gatewayStripe">Stripe</button>
1104
+ <button class="gateway-btn" id="gatewayCashfree">Cashfree</button>
1105
+ <button class="gateway-btn" id="gatewayMock">Mock (dev)</button>
1106
+ </div>
1107
+ <div class="paywall-actions">
1108
+ <button class="btn-secondary" id="backToIntroBtn">Back</button>
1109
+ </div>
1110
+ </div>
1111
+ <div class="paywall-step" id="paywallStepCheckout">
1112
+ <!-- Cashfree Drop-in container (used when Cashfree SDK is available) -->
1113
+ <div id="cfDropin" style="width:100%;height:100%;display:none;background:#fff;border-radius:8px;overflow:auto;"></div>
1114
+ <iframe class="paywall-iframe" id="paywallFrame" src="about:blank"></iframe>
1115
+ <div class="paywall-actions">
1116
+ <a class="btn-primary" id="openCheckoutLink" href="#" target="_blank" rel="noopener noreferrer">Open in new tab</a>
1117
+ <button class="btn-secondary" id="backToGatewaysBtn">Back</button>
1118
+ </div>
1119
+ </div>
1120
+ </div>
1121
+ </div>
1122
+ </div>
1123
+
1124
+ <!-- Bottom Gradient -->
1125
+ <div class="controls-gradient"></div>
1126
+
1127
+ <!-- Controls Bar -->
1128
+ <div class="controls-bar" id="controlsBar">
1129
+ <!-- Progress Section -->
1130
+ <div class="progress-section">
1131
+ <div class="progress-bar-wrapper" id="progressBar">
1132
+ <div class="progress-buffered" id="progressBuffered"></div>
1133
+ <div class="progress-filled" id="progressFilled"></div>
1134
+ <div class="progress-handle" id="progressHandle"></div>
1135
+ <div class="time-tooltip" id="timeTooltip">00:00</div>
1136
+ </div>
1137
+ </div>
1138
+
1139
+ <!-- Controls Row -->
1140
+ <div class="controls-row">
1141
+ <!-- Play/Pause Button -->
1142
+ <button class="control-btn play-pause" id="playPauseBtn" title="Play (Space)">
1143
+ <svg viewBox="0 0 24 24" id="playIcon">
1144
+ <path d="M8 5v14l11-7z"/>
1145
+ </svg>
1146
+ <svg viewBox="0 0 24 24" id="pauseIcon" style="display: none;">
1147
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1148
+ </svg>
1149
+ </button>
1150
+
1151
+ <!-- Back Button -->
1152
+ <button class="control-btn" id="backBtn" title="Back 10s (←)">
1153
+ <svg viewBox="0 0 24 24">
1154
+ <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"/>
1155
+ <text x="12" y="16" text-anchor="middle" fill="currentColor" font-size="9" font-weight="bold">10</text>
1156
+ </svg>
1157
+ </button>
1158
+
1159
+ <!-- Forward Button -->
1160
+ <button class="control-btn" id="forwardBtn" title="Forward 10s (→)">
1161
+ <svg viewBox="0 0 24 24">
1162
+ <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"/>
1163
+ <text x="12" y="16" text-anchor="middle" fill="currentColor" font-size="9" font-weight="bold">10</text>
1164
+ </svg>
1165
+ </button>
1166
+
1167
+ <!-- Volume Control -->
1168
+ <div class="volume-control">
1169
+ <button class="control-btn" id="volumeBtn" title="Mute (M)">
1170
+ <svg viewBox="0 0 24 24" id="volumeIcon">
1171
+ <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"/>
1172
+ </svg>
1173
+ <svg viewBox="0 0 24 24" id="volumeMuteIcon" style="display: none;">
1174
+ <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"/>
1175
+ </svg>
1176
+ </button>
1177
+ <div class="volume-panel" id="volumePanel">
1178
+ <div class="volume-slider" id="volumeSlider">
1179
+ <div class="volume-fill" id="volumeFill" style="width: 100%;">
1180
+ <div class="volume-handle"></div>
1181
+ </div>
1182
+ </div>
1183
+ <div class="volume-value" id="volumeValue">100</div>
1184
+ </div>
1185
+ </div>
1186
+
1187
+ <!-- Time Display -->
1188
+ <div class="time-display" id="timeDisplay">00:00 / 00:00</div>
1189
+
1190
+ <!-- Right Controls -->
1191
+ <div class="right-controls">
1192
+ <!-- Quality Badge -->
1193
+ <div class="quality-badge" id="qualityBadge">HD</div>
1194
+
1195
+ <!-- Settings Button -->
1196
+ <div class="settings-btn">
1197
+ <button class="control-btn" id="settingsBtn" title="Settings">
1198
+ <svg viewBox="0 0 24 24">
1199
+ <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"/>
1200
+ </svg>
1201
+ </button>
1202
+
1203
+ <!-- Settings Menu -->
1204
+ <div class="settings-menu" id="settingsMenu">
1205
+ <div class="settings-group" id="speedGroup">
1206
+ <div class="settings-label">Playback Speed</div>
1207
+ <div class="settings-option speed-option" data-speed="0.5">0.5x</div>
1208
+ <div class="settings-option speed-option" data-speed="0.75">0.75x</div>
1209
+ <div class="settings-option speed-option active" data-speed="1">Normal</div>
1210
+ <div class="settings-option speed-option" data-speed="1.25">1.25x</div>
1211
+ <div class="settings-option speed-option" data-speed="1.5">1.5x</div>
1212
+ <div class="settings-option speed-option" data-speed="2">2x</div>
1213
+ </div>
1214
+ <div class="settings-group" id="subtitleGroup">
1215
+ <div class="settings-label">Subtitles</div>
1216
+ <div id="subtitleOptions">
1217
+ <div class="settings-option subtitle-option active" data-subtitle="off">Off</div>
1218
+ </div>
1219
+ </div>
1220
+ <div class="settings-group" id="qualityGroup">
1221
+ <div class="settings-label">Quality</div>
1222
+ <div id="qualityOptions">
1223
+ <div class="settings-option quality-option active" data-quality="auto">Auto</div>
1224
+ <!-- Dynamic quality options will be injected here -->
1225
+ </div>
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <!-- Picture-in-Picture Button -->
1231
+ <button class="control-btn" id="pipBtn" title="Picture-in-Picture">
1232
+ <svg viewBox="0 0 24 24">
1233
+ <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"/>
1234
+ </svg>
1235
+ </button>
1236
+
1237
+ <!-- Fullscreen Button -->
1238
+ <button class="control-btn" id="fullscreenBtn" title="Fullscreen (F)">
1239
+ <svg viewBox="0 0 24 24" id="fullscreenIcon">
1240
+ <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
1241
+ </svg>
1242
+ <svg viewBox="0 0 24 24" id="fullscreenExitIcon" style="display: none;">
1243
+ <path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
1244
+ </svg>
1245
+ </button>
1246
+ </div>
1247
+ </div>
1248
+ </div>
1249
+ </div>
1250
+ </div>
1251
+
1252
+ <script>
1253
+ // Google Cast: framework availability handler and helper
1254
+ // Debug helper for targeted cast logging
1255
+ (function(){
1256
+ const params = new URLSearchParams(window.location.search);
1257
+ const raw = (params.get('debug') || '').toLowerCase();
1258
+ const enabled = raw === '1' || raw === 'true' || raw === '2' || raw.includes('cast');
1259
+ window.__dbg = {
1260
+ enabled,
1261
+ log: function(){ if (!enabled) return; try { console.log('[CastDbg]', ...arguments); } catch(_){} },
1262
+ warn: function(){ if (!enabled) return; try { console.warn('[CastDbg]', ...arguments); } catch(_){} },
1263
+ error: function(){ if (!enabled) return; try { console.error('[CastDbg]', ...arguments); } catch(_){} }
1264
+ };
1265
+ })();
1266
+ window.__onGCastApiAvailable = function(isAvailable) {
1267
+ if (isAvailable && window.cast && cast.framework) {
1268
+ const context = cast.framework.CastContext.getInstance();
1269
+ const options = {
1270
+ receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID
1271
+ };
1272
+ try {
1273
+ if (window.chrome && chrome.cast && chrome.cast.AutoJoinPolicy && chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED) {
1274
+ options.autoJoinPolicy = chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED;
1275
+ }
1276
+ } catch (_) {}
1277
+ context.setOptions(options);
1278
+ try { console.log('[Cast] Framework ready'); } catch (_) {}
1279
+ // Notify the demo player instance (if already created)
1280
+ try { window.enhancedPlayer?.setupCastContext?.(); } catch (_) {}
1281
+ } else {
1282
+ try { console.warn('[Cast] Cast API not available'); } catch (_) {}
1283
+ }
1284
+ };
1285
+
1286
+ // Helper to start a Cast session and load current media from the demo player
1287
+ window.initCastFrameworkForPlayer = async function(player) {
1288
+ try {
1289
+ if (!(window.cast && cast.framework)) {
1290
+ console.warn('[Cast] Framework not ready');
1291
+ alert('Cast framework not ready yet. Please wait a moment and try again.');
1292
+ return;
1293
+ }
1294
+
1295
+ const context = cast.framework.CastContext.getInstance();
1296
+ // Ensure player bound to context
1297
+ try { player?.setupCastContext?.(); } catch (_) {}
1298
+
1299
+ __dbg?.log && __dbg.log('Requesting cast session');
1300
+ await context.requestSession(); // opens device picker
1301
+ const session = context.getCurrentSession();
1302
+ try {
1303
+ const id = (session && (typeof session.getSessionId === 'function' ? session.getSessionId() : session.sessionId)) || null;
1304
+ let device = null;
1305
+ if (session && typeof session.getCastDevice === 'function') {
1306
+ const dev = session.getCastDevice();
1307
+ device = (dev && dev.friendlyName) || null;
1308
+ }
1309
+ __dbg?.log && __dbg.log('Session current', { id, device });
1310
+ } catch(_) {}
1311
+ if (!session) { try { __dbg?.warn && __dbg.warn('No session after requestSession'); } catch(_) {} return; }
1312
+
1313
+ const url = (player && (player.currentSourceUrl || player.video?.src)) || '';
1314
+ const u = (url || '').toLowerCase();
1315
+ const contentType = u.includes('.m3u8') ? 'application/x-mpegurl'
1316
+ : u.includes('.mpd') ? 'application/dash+xml'
1317
+ : u.includes('.webm') ? 'video/webm'
1318
+ : 'video/mp4';
1319
+
1320
+ // Build MediaInfo with metadata and tracks (for subtitles)
1321
+ const mediaInfo = new chrome.cast.media.MediaInfo(url, contentType);
1322
+ mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
1323
+ try {
1324
+ const md = new chrome.cast.media.GenericMediaMetadata();
1325
+ md.title = (player && player.video && player.video.currentSrc) ? (player.video.currentSrc.split('/').slice(-1)[0]) : 'Enhanced Player';
1326
+ mediaInfo.metadata = md;
1327
+ } catch (_) {}
1328
+
1329
+ // Subtitle tracks -> Cast tracks
1330
+ const castTracks = [];
1331
+ player._castTrackIdByKey = {};
1332
+ const inferTextTrackContentType = (trackUrl) => {
1333
+ const lu = (trackUrl || '').toLowerCase();
1334
+ if (lu.endsWith('.vtt')) return 'text/vtt';
1335
+ if (lu.endsWith('.srt')) return 'application/x-subrip';
1336
+ if (lu.endsWith('.ttml') || lu.endsWith('.dfxp') || lu.endsWith('.xml')) return 'application/ttml+xml';
1337
+ return 'text/vtt';
1338
+ };
1339
+ if (Array.isArray(player?.subtitleTracks) && player.subtitleTracks.length > 0) {
1340
+ let nextId = 1;
1341
+ for (let i = 0; i < player.subtitleTracks.length; i++) {
1342
+ const t = player.subtitleTracks[i];
1343
+ const key = t.label || t.language || `Track ${i+1}`;
1344
+ try {
1345
+ const track = new chrome.cast.media.Track(nextId, chrome.cast.media.TrackType.TEXT);
1346
+ track.trackContentId = t.url;
1347
+ track.trackContentType = inferTextTrackContentType(t.url);
1348
+ track.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
1349
+ track.name = key;
1350
+ track.language = t.language || '';
1351
+ track.customData = t.customData || null;
1352
+ castTracks.push(track);
1353
+ player._castTrackIdByKey[key] = nextId;
1354
+ nextId++;
1355
+ } catch (_) {}
1356
+ }
1357
+ }
1358
+ if (castTracks.length > 0) {
1359
+ mediaInfo.tracks = castTracks;
1360
+ try {
1361
+ const style = new chrome.cast.media.TextTrackStyle();
1362
+ style.backgroundColor = '#00000000'; // transparent
1363
+ style.foregroundColor = '#FFFFFFFF'; // white
1364
+ style.edgeType = chrome.cast.media.TextTrackEdgeType.DROP_SHADOW;
1365
+ style.edgeColor = '#000000FF';
1366
+ style.fontScale = 1.0;
1367
+ mediaInfo.textTrackStyle = style;
1368
+ } catch (_) {}
1369
+ }
1370
+
1371
+ const request = new chrome.cast.media.LoadRequest(mediaInfo);
1372
+ request.autoplay = true;
1373
+ try { request.currentTime = Math.max(0, Math.floor(player?.video?.currentTime || 0)); } catch (_) {}
1374
+ if (player && player._castTrackIdByKey && player.selectedSubtitle && player.selectedSubtitle !== 'off') {
1375
+ const tid = player._castTrackIdByKey[player.selectedSubtitle];
1376
+ if (tid) request.activeTrackIds = [tid];
1377
+ }
1378
+
1379
+ __dbg?.log && __dbg.log('Loading media', { url, contentType, tracks: (mediaInfo.tracks||[]).map(t=>({id:t.trackId,name:t.name,lang:t.language,type:t.trackContentType})) });
1380
+ await session.loadMedia(request);
1381
+ __dbg?.log && __dbg.log('Media loaded on receiver');
1382
+ try { player?.enableCastRemoteControl?.(); } catch (_) {}
1383
+ player?.showNotification?.('Casting to device');
1384
+ } catch (err) {
1385
+ console.error('[Cast] requestSession/loadMedia failed:', err);
1386
+ player?.showNotification?.('Cast failed');
1387
+ }
1388
+ };
1389
+
1390
+ // Simple EventEmitter for the demo
1391
+ class EventEmitter {
1392
+ constructor() {
1393
+ this._events = {};
1394
+ }
1395
+ on(event, listener) {
1396
+ if (!this._events[event]) this._events[event] = new Set();
1397
+ this._events[event].add(listener);
1398
+ return () => this.off(event, listener);
1399
+ }
1400
+ off(event, listener) {
1401
+ if (!this._events[event]) return;
1402
+ this._events[event].delete(listener);
1403
+ }
1404
+ once(event, listener) {
1405
+ const off = this.on(event, (...args) => {
1406
+ off();
1407
+ listener(...args);
1408
+ });
1409
+ return off;
1410
+ }
1411
+ emit(event, ...args) {
1412
+ if (!this._events[event]) return;
1413
+ for (const listener of Array.from(this._events[event])) {
1414
+ try { listener(...args); } catch (e) { console.error('Event listener error', e); }
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ class EnhancedVideoPlayer extends EventEmitter {
1420
+ constructor() {
1421
+ super();
1422
+ this.video = document.getElementById('videoPlayer');
1423
+ // Enable CORS for external tracks and media where supported
1424
+ try { this.video.crossOrigin = 'anonymous'; } catch (_) {}
1425
+ this.playerWrapper = document.getElementById('playerWrapper');
1426
+ this.controlsBar = document.getElementById('controlsBar');
1427
+ this.playPauseBtn = document.getElementById('playPauseBtn');
1428
+ this.centerPlayBtn = document.getElementById('centerPlayBtn');
1429
+ this.progressBar = document.getElementById('progressBar');
1430
+ this.progressFilled = document.getElementById('progressFilled');
1431
+ this.progressBuffered = document.getElementById('progressBuffered');
1432
+ this.progressHandle = document.getElementById('progressHandle');
1433
+ this.timeTooltip = document.getElementById('timeTooltip');
1434
+ this.timeDisplay = document.getElementById('timeDisplay');
1435
+ this.volumeBtn = document.getElementById('volumeBtn');
1436
+ this.volumePanel = document.getElementById('volumePanel');
1437
+ this.volumeSlider = document.getElementById('volumeSlider');
1438
+ this.volumeFill = document.getElementById('volumeFill');
1439
+ this.volumeValue = document.getElementById('volumeValue');
1440
+ this.settingsBtn = document.getElementById('settingsBtn');
1441
+ this.settingsMenu = document.getElementById('settingsMenu');
1442
+ this.subtitleOptionsEl = document.getElementById('subtitleOptions');
1443
+ this.qualityOptionsEl = document.getElementById('qualityOptions');
1444
+ this.pipBtn = document.getElementById('pipBtn');
1445
+ this.fullscreenBtn = document.getElementById('fullscreenBtn');
1446
+ this.loadingContainer = document.getElementById('loadingContainer');
1447
+ this.watermarkCanvas = document.getElementById('watermarkCanvas');
1448
+ this.shortcutIndicator = document.getElementById('shortcutIndicator');
1449
+
1450
+ // Streaming/quality state
1451
+ this.hls = null;
1452
+ this.dash = null;
1453
+ this.qualities = [];
1454
+ this.currentQualityIndex = -1;
1455
+ this.autoQuality = true;
1456
+ this.sourceType = 'mp4';
1457
+ this.currentSourceUrl = '';
1458
+ this.subtitleTracks = [];
1459
+ this.selectedSubtitle = 'off';
1460
+
1461
+ // Requested config per source (parity with PlayerConfig)
1462
+ this.requestedConfig = {
1463
+ autoPlay: false,
1464
+ muted: undefined,
1465
+ loop: undefined,
1466
+ playsInline: undefined,
1467
+ preload: undefined,
1468
+ crossOrigin: undefined,
1469
+ enableAdaptiveBitrate: true
1470
+ };
1471
+ this.autoPlayRequested = false;
1472
+
1473
+ this.hideControlsTimeout = null;
1474
+ this.volumeHideTimeout = null;
1475
+ this.isPlaying = false;
1476
+ this.isDragging = false;
1477
+ this.isVolumeSliding = false;
1478
+
1479
+ // Cast state
1480
+ this.castContext = null;
1481
+ this.remotePlayer = null;
1482
+ this.remoteController = null;
1483
+ this.isCasting = false;
1484
+ this._castHandlers = {};
1485
+ this._castTrackIdByKey = {};
1486
+
1487
+ this.init();
1488
+ // Try to bind cast context if framework is already available
1489
+ this.setupCastContextSafe();
1490
+ }
1491
+
1492
+ init() {
1493
+ this.setupEventListeners();
1494
+ this.setupKeyboardShortcuts();
1495
+ this.setupWatermark();
1496
+
1497
+ // Demo rental config
1498
+ this.demoRental = {
1499
+ enabled: true,
1500
+ userId: 'u1',
1501
+ videoId: 'v1',
1502
+ apiBase: 'http://localhost:3100',
1503
+ entitled: false,
1504
+ freePreviewSeconds: 60,
1505
+ gateway: 'stripe'
1506
+ };
1507
+ // Allow overriding free preview via query param (?preview=SECONDS)
1508
+ try {
1509
+ const params = new URLSearchParams(window.location.search);
1510
+ const fp = parseFloat(params.get('preview') || params.get('free') || params.get('freePreview') || '');
1511
+ if (!isNaN(fp) && fp >= 0) this.demoRental.freePreviewSeconds = fp;
1512
+ const gw = (params.get('gateway') || '').toLowerCase();
1513
+ if (gw === 'stripe' || gw === 'pesapal' || gw === 'mock') this.demoRental.gateway = gw;
1514
+ } catch (_) {}
1515
+ // Internal gate state
1516
+ this._previewGateHit = false;
1517
+
1518
+ // If this page is loaded inside a checkout popup window, notify opener and close
1519
+ try {
1520
+ const params = new URLSearchParams(window.location.search);
1521
+ const popup = (params.get('popup') || '').toLowerCase() === '1';
1522
+ const rental = (params.get('rental') || '').toLowerCase();
1523
+ const orderId = params.get('order_id') || '';
1524
+ const sessionId = params.get('session_id') || '';
1525
+ if (popup && (rental === 'success' || rental === 'cancel')) {
1526
+ try { if (window.opener) window.opener.postMessage({ type: 'uvfCheckout', status: rental, orderId, sessionId }, '*'); } catch(_) {}
1527
+ try { window.close(); } catch(_) {}
1528
+ }
1529
+ } catch (_) {}
1530
+
1531
+ // Apply default theme (red)
1532
+ this.setTheme({ accent: '#ff0000', accent2: '#ff4d4f', iconColor: '#ffffff', textPrimary: '#ffffff', textSecondary: 'rgba(255,255,255,0.75)' });
1533
+
1534
+ // Initialize metadata UI hidden/empty
1535
+ this.updateMetadataUI(null);
1536
+
1537
+ // Expose for console testing
1538
+ window.enhancedPlayer = this;
1539
+
1540
+ // Wire paywall modal and initial entitlement check
1541
+ this.setupPaywallUI();
1542
+ this.checkDemoEntitlement();
1543
+ // When free preview ends, open paywall modal (80% overlay)
1544
+ try { this.on('onFreePreviewEnded', () => { this.showPaywallOverlay('intro'); }); } catch (_) {}
1545
+
1546
+ // Load sample MP4 by default
1547
+ this.loadSource('https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4');
1548
+ }
1549
+
1550
+ setupCastContextSafe() {
1551
+ try {
1552
+ if (window.cast && cast.framework) {
1553
+ this.setupCastContext();
1554
+ }
1555
+ } catch (_) {}
1556
+ }
1557
+
1558
+ setupCastContext() {
1559
+ if (this.castContext) return;
1560
+ try {
1561
+ this.castContext = cast.framework.CastContext.getInstance();
1562
+ this.castContext.addEventListener(
1563
+ cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
1564
+ (ev) => {
1565
+ const state = ev.sessionState;
1566
+ try { __dbg?.log && __dbg.log('SESSION_STATE_CHANGED', state); } catch(_) {}
1567
+ if (state === cast.framework.SessionState.SESSION_STARTED ||
1568
+ state === cast.framework.SessionState.SESSION_RESUMED) {
1569
+ this.enableCastRemoteControl();
1570
+ } else if (state === cast.framework.SessionState.SESSION_ENDED) {
1571
+ this.disableCastRemoteControl();
1572
+ }
1573
+ }
1574
+ );
1575
+ } catch (err) {
1576
+ console.warn('[Cast] setupCastContext failed', err);
1577
+ }
1578
+ }
1579
+
1580
+ enableCastRemoteControl() {
1581
+ try {
1582
+ if (!(window.cast && cast.framework)) return;
1583
+ const session = cast.framework.CastContext.getInstance().getCurrentSession();
1584
+ if (!session) return;
1585
+ if (!this.remotePlayer) {
1586
+ this.remotePlayer = new cast.framework.RemotePlayer();
1587
+ }
1588
+ if (!this.remoteController) {
1589
+ this.remoteController = new cast.framework.RemotePlayerController(this.remotePlayer);
1590
+ // Bind remote events once
1591
+ this._bindRemotePlayerEvents();
1592
+ }
1593
+ this.isCasting = true;
1594
+ // Pause local playback while casting
1595
+ try { this.video.pause(); } catch (_) {}
1596
+ // Update UI to reflect remote state immediately
1597
+ this._syncUIFromRemote();
1598
+ this._syncCastButtons();
1599
+ } catch (err) {
1600
+ console.warn('[Cast] enableCastRemoteControl failed', err);
1601
+ }
1602
+ }
1603
+
1604
+ disableCastRemoteControl() {
1605
+ this.isCasting = false;
1606
+ // Keep remote controller instances (they will reconnect if session resumes)
1607
+ // Optionally resume local UI state
1608
+ this.syncFullscreenUI();
1609
+ this._syncCastButtons();
1610
+ }
1611
+
1612
+ _bindRemotePlayerEvents() {
1613
+ if (!this.remoteController) return;
1614
+ const c = this.remoteController;
1615
+ const RPET = cast.framework.RemotePlayerEventType;
1616
+ const on = (type, fn) => {
1617
+ const handler = (e) => { try { fn(e); } catch (err) { console.warn('[Cast] handler error', err); } };
1618
+ c.addEventListener(type, handler);
1619
+ this._castHandlers[type] = handler;
1620
+ };
1621
+
1622
+ on(RPET.IS_PAUSED_CHANGED, () => {
1623
+ if (!this.isCasting) return;
1624
+ if (this.remotePlayer && this.remotePlayer.isPaused === false) {
1625
+ this.onPlay();
1626
+ } else {
1627
+ this.onPause();
1628
+ }
1629
+ });
1630
+ on(RPET.CURRENT_TIME_CHANGED, () => {
1631
+ if (!this.isCasting) return;
1632
+ if (typeof this.remotePlayer.currentTime === 'number' && typeof this.remotePlayer.duration === 'number') {
1633
+ // Update UI similar to local updateProgress
1634
+ const duration = this.remotePlayer.duration || 0;
1635
+ const current = Math.max(0, Math.min(this.remotePlayer.currentTime || 0, duration));
1636
+ const percent = duration > 0 ? (current / duration) * 100 : 0;
1637
+ this.progressFilled.style.width = percent + '%';
1638
+ this.progressHandle.style.left = percent + '%';
1639
+ this.timeDisplay.textContent = `${this.formatTime(current)} / ${this.formatTime(duration)}`;
1640
+ this.emit('timeupdate', current);
1641
+ this.emit('onTimeUpdate', current);
1642
+ try { this.enforceFreePreviewGate(current); } catch(_) {}
1643
+ }
1644
+ });
1645
+ on(RPET.DURATION_CHANGED, () => {
1646
+ if (!this.isCasting) return;
1647
+ const duration = this.remotePlayer?.duration || 0;
1648
+ this.timeDisplay.textContent = `${this.formatTime(this.remotePlayer?.currentTime || 0)} / ${this.formatTime(duration)}`;
1649
+ });
1650
+ on(RPET.PLAYER_STATE_CHANGED, () => {
1651
+ if (!this.isCasting) return;
1652
+ const state = this.remotePlayer?.playerState;
1653
+ if (state === chrome.cast.media.PlayerState.BUFFERING) {
1654
+ this.showLoading();
1655
+ this.emit('onBuffering', true);
1656
+ } else {
1657
+ this.hideLoading();
1658
+ this.emit('onBuffering', false);
1659
+ }
1660
+ });
1661
+ on(RPET.IS_MUTED_CHANGED, () => {
1662
+ if (!this.isCasting) return;
1663
+ this.updateVolumeIcon();
1664
+ this.emit('volumechange', this.remotePlayer?.volumeLevel || 0);
1665
+ this.emit('onVolumeChanged', this.remotePlayer?.volumeLevel || 0);
1666
+ });
1667
+ on(RPET.VOLUME_LEVEL_CHANGED, () => {
1668
+ if (!this.isCasting) return;
1669
+ const level = this.remotePlayer?.volumeLevel || 0;
1670
+ this.volumeFill.style.width = Math.round(level * 100) + '%';
1671
+ this.volumeValue.textContent = String(Math.round(level * 100));
1672
+ this.updateVolumeIcon();
1673
+ this.emit('volumechange', level);
1674
+ this.emit('onVolumeChanged', level);
1675
+ });
1676
+ on(RPET.IS_CONNECTED_CHANGED, () => {
1677
+ if (!this.remotePlayer?.isConnected) {
1678
+ this.disableCastRemoteControl();
1679
+ }
1680
+ });
1681
+ }
1682
+
1683
+ _syncUIFromRemote() {
1684
+ if (!this.remotePlayer) return;
1685
+ const isPaused = !!this.remotePlayer.isPaused;
1686
+ if (isPaused) {
1687
+ this.onPause();
1688
+ } else {
1689
+ this.onPlay();
1690
+ }
1691
+ const duration = this.remotePlayer.duration || 0;
1692
+ const current = this.remotePlayer.currentTime || 0;
1693
+ const percent = duration > 0 ? (current / duration) * 100 : 0;
1694
+ this.progressFilled.style.width = percent + '%';
1695
+ this.progressHandle.style.left = percent + '%';
1696
+ this.timeDisplay.textContent = `${this.formatTime(current)} / ${this.formatTime(duration)}`;
1697
+ const level = this.remotePlayer.volumeLevel || 0;
1698
+ this.volumeFill.style.width = Math.round(level * 100) + '%';
1699
+ this.volumeValue.textContent = String(Math.round(level * 100));
1700
+ this.updateVolumeIcon();
1701
+ }
1702
+
1703
+ setupEventListeners() {
1704
+ // Disable right-click
1705
+ this.video.addEventListener('contextmenu', (e) => {
1706
+ e.preventDefault();
1707
+ return false;
1708
+ });
1709
+
1710
+ this.playerWrapper.addEventListener('contextmenu', (e) => {
1711
+ e.preventDefault();
1712
+ return false;
1713
+ });
1714
+
1715
+ // Play/Pause
1716
+ this.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
1717
+ this.centerPlayBtn.addEventListener('click', () => this.togglePlayPause());
1718
+ this.video.addEventListener('click', () => this.togglePlayPause());
1719
+
1720
+ // Video events
1721
+ this.video.addEventListener('play', async () => {
1722
+ // Do not block playback here; gate is enforced on timeupdate/seek.
1723
+ // Optionally refresh entitlement in background
1724
+ try { this.checkDemoEntitlement(); } catch(_) {}
1725
+ this.onPlay();
1726
+ });
1727
+ this.video.addEventListener('pause', () => this.onPause());
1728
+ this.video.addEventListener('timeupdate', () => {
1729
+ this.updateProgress();
1730
+ try { this.enforceFreePreviewGate(this.video.currentTime || 0); } catch(_) {}
1731
+ });
1732
+ this.video.addEventListener('progress', () => this.updateBuffered());
1733
+ this.video.addEventListener('loadedmetadata', () => this.onLoadedMetadata());
1734
+ this.video.addEventListener('waiting', () => { this.showLoading(); this.emit('onBuffering', true); });
1735
+ this.video.addEventListener('canplay', () => {
1736
+ this.hideLoading();
1737
+ this.emit('onBuffering', false);
1738
+ // Emit readiness (align with WebPlayer.ts)
1739
+ this.emit('ready');
1740
+ this.emit('onReady');
1741
+ if (this.autoPlayRequested) {
1742
+ this.video.play().catch(() => {});
1743
+ }
1744
+ });
1745
+ this.video.addEventListener('ended', () => this.onEnded());
1746
+
1747
+ // Additional parity events
1748
+ this.video.addEventListener('seeking', () => { this.emit('seeking'); this.emit('onSeeking'); });
1749
+ this.video.addEventListener('seeked', () => { this.emit('seeked'); this.emit('onSeeked'); try { this.enforceFreePreviewGate(this.video.currentTime || 0, true); } catch(_) {} });
1750
+ this.video.addEventListener('volumechange', () => { this.emit('volumechange', this.video.volume); this.emit('onVolumeChanged', this.video.volume); });
1751
+
1752
+ // Track mouse movement for auto-hide
1753
+ let mouseTimer = null;
1754
+ this.video.addEventListener('mousemove', () => {
1755
+ this.showControls();
1756
+ if (this.isPlaying) {
1757
+ this.scheduleHideControls();
1758
+ }
1759
+ });
1760
+
1761
+ // Progress bar
1762
+ this.progressBar.addEventListener('click', (e) => this.seek(e));
1763
+ this.progressBar.addEventListener('mousedown', () => this.isDragging = true);
1764
+ this.progressBar.addEventListener('mousemove', (e) => this.updateTimeTooltip(e));
1765
+ this.progressBar.addEventListener('mouseleave', () => this.hideTimeTooltip());
1766
+ document.addEventListener('mouseup', () => this.isDragging = false);
1767
+ document.addEventListener('mousemove', (e) => {
1768
+ if (this.isDragging && e.target === this.progressBar) {
1769
+ this.seek(e);
1770
+ }
1771
+ });
1772
+
1773
+ // Back/Forward
1774
+ document.getElementById('backBtn').addEventListener('click', () => this.skip(-10));
1775
+ document.getElementById('forwardBtn').addEventListener('click', () => this.skip(10));
1776
+
1777
+ // Volume
1778
+ this.volumeBtn.addEventListener('click', (e) => {
1779
+ e.stopPropagation();
1780
+ this.toggleMute();
1781
+ });
1782
+
1783
+ // Volume button hover to show panel
1784
+ this.volumeBtn.addEventListener('mouseenter', () => {
1785
+ clearTimeout(this.volumeHideTimeout);
1786
+ this.volumePanel.classList.add('active');
1787
+ });
1788
+
1789
+ this.volumeBtn.addEventListener('mouseleave', () => {
1790
+ // Delay hiding when leaving volume button
1791
+ this.volumeHideTimeout = setTimeout(() => {
1792
+ if (!this.volumePanel.matches(':hover')) {
1793
+ this.volumePanel.classList.remove('active');
1794
+ }
1795
+ }, 800); // 800ms delay when leaving button
1796
+ });
1797
+
1798
+ // Volume panel interactions
1799
+ this.volumePanel.addEventListener('mouseenter', () => {
1800
+ clearTimeout(this.volumeHideTimeout);
1801
+ this.volumePanel.classList.add('active');
1802
+ });
1803
+
1804
+ this.volumePanel.addEventListener('mouseleave', () => {
1805
+ if (!this.isVolumeSliding) {
1806
+ // Delay hiding the panel for better usability
1807
+ setTimeout(() => {
1808
+ if (!this.volumePanel.matches(':hover') && !this.volumeBtn.matches(':hover')) {
1809
+ this.volumePanel.classList.remove('active');
1810
+ }
1811
+ }, 1500); // 1.5 seconds delay
1812
+ }
1813
+ });
1814
+
1815
+ // Volume slider interactions
1816
+ this.volumeSlider.addEventListener('mousedown', (e) => {
1817
+ e.stopPropagation();
1818
+ this.isVolumeSliding = true;
1819
+ this.volumePanel.classList.add('active');
1820
+ this.setVolume(e);
1821
+ });
1822
+
1823
+ this.volumeSlider.addEventListener('click', (e) => {
1824
+ e.stopPropagation();
1825
+ this.setVolume(e);
1826
+ });
1827
+
1828
+ document.addEventListener('mousemove', (e) => {
1829
+ if (this.isVolumeSliding) {
1830
+ this.setVolume(e);
1831
+ }
1832
+ });
1833
+
1834
+ document.addEventListener('mouseup', () => {
1835
+ if (this.isVolumeSliding) {
1836
+ this.isVolumeSliding = false;
1837
+ // Keep panel visible for longer after dragging
1838
+ setTimeout(() => {
1839
+ if (!this.volumePanel.matches(':hover') && !this.volumeBtn.matches(':hover')) {
1840
+ this.volumePanel.classList.remove('active');
1841
+ }
1842
+ }, 2000); // 2 seconds delay after dragging
1843
+ }
1844
+ });
1845
+
1846
+ // Settings
1847
+ this.settingsBtn.addEventListener('click', () => {
1848
+ this.settingsMenu.classList.toggle('active');
1849
+ });
1850
+
1851
+ // Speed options
1852
+ document.querySelectorAll('.speed-option').forEach(option => {
1853
+ option.addEventListener('click', (e) => {
1854
+ const speed = parseFloat(e.target.dataset.speed);
1855
+ this.setSpeed(speed);
1856
+ });
1857
+ });
1858
+
1859
+ // Dynamic Quality options (event delegation)
1860
+ if (this.qualityOptionsEl) {
1861
+ this.qualityOptionsEl.addEventListener('click', (e) => {
1862
+ const opt = e.target.closest('.quality-option');
1863
+ if (!opt) return;
1864
+ const qAuto = opt.dataset.quality === 'auto';
1865
+ if (qAuto) {
1866
+ this.setAutoQuality(true);
1867
+ } else if (typeof opt.dataset.index !== 'undefined') {
1868
+ const idx = parseInt(opt.dataset.index, 10);
1869
+ if (!isNaN(idx)) this.setQualityIndex(idx);
1870
+ }
1871
+ });
1872
+ }
1873
+
1874
+ // Dynamic Subtitle options (event delegation)
1875
+ if (this.subtitleOptionsEl) {
1876
+ this.subtitleOptionsEl.addEventListener('click', (e) => {
1877
+ const opt = e.target.closest('.subtitle-option');
1878
+ if (!opt) return;
1879
+ const key = opt.dataset.subtitle || 'off';
1880
+ this.selectSubtitle(key);
1881
+ });
1882
+ }
1883
+
1884
+ // Picture-in-Picture
1885
+ this.pipBtn.addEventListener('click', () => this.togglePiP());
1886
+
1887
+ // Fullscreen (call without await to keep user gesture)
1888
+ this.fullscreenBtn.addEventListener('click', () => { this.toggleFullscreen(); });
1889
+ // Listen to fullscreen change events to sync UI
1890
+ ;['fullscreenchange','webkitfullscreenchange','msfullscreenchange'].forEach(ev =>
1891
+ document.addEventListener(ev, () => this.syncFullscreenUI())
1892
+ );
1893
+
1894
+ // Hide controls on idle
1895
+ this.playerWrapper.addEventListener('mousemove', () => {
1896
+ this.showControls();
1897
+ if (this.isPlaying) {
1898
+ this.scheduleHideControls();
1899
+ }
1900
+ });
1901
+
1902
+ this.playerWrapper.addEventListener('mouseleave', () => {
1903
+ if (this.isPlaying) {
1904
+ this.hideControls();
1905
+ }
1906
+ });
1907
+
1908
+ // Handle mouse over controls to prevent hiding
1909
+ this.controlsBar.addEventListener('mouseenter', () => {
1910
+ clearTimeout(this.hideControlsTimeout);
1911
+ });
1912
+
1913
+ this.controlsBar.addEventListener('mouseleave', () => {
1914
+ if (this.isPlaying) {
1915
+ this.scheduleHideControls();
1916
+ }
1917
+ });
1918
+
1919
+ // Hide menus when clicking outside
1920
+ document.addEventListener('click', (e) => {
1921
+ if (!e.target.closest('.settings-btn')) {
1922
+ this.settingsMenu.classList.remove('active');
1923
+ }
1924
+ });
1925
+
1926
+ // Cast button functionality
1927
+ const castBtn = document.getElementById('castBtn');
1928
+ if (castBtn) {
1929
+ castBtn.addEventListener('click', () => this.onCastButtonClick());
1930
+ }
1931
+ const stopCastBtn = document.getElementById('stopCastBtn');
1932
+ if (stopCastBtn) {
1933
+ stopCastBtn.addEventListener('click', () => this.stopCasting());
1934
+ }
1935
+
1936
+ // Playlist button
1937
+ const playlistBtn = document.getElementById('playlistBtn');
1938
+ if (playlistBtn) {
1939
+ playlistBtn.addEventListener('click', () => {
1940
+ this.showNotification('Added to playlist');
1941
+ });
1942
+ }
1943
+
1944
+ // Share button
1945
+ const shareBtn = document.getElementById('shareBtn');
1946
+ if (shareBtn) {
1947
+ shareBtn.addEventListener('click', () => this.shareVideo());
1948
+ }
1949
+
1950
+ // Initialize volume
1951
+ this.video.volume = 1;
1952
+ this.updateVolumeDisplay();
1953
+ }
1954
+
1955
+ setupKeyboardShortcuts() {
1956
+ document.addEventListener('keydown', (e) => {
1957
+ if (e.target.tagName === 'INPUT') return;
1958
+
1959
+
1960
+ let shortcutText = '';
1961
+
1962
+ switch(e.key) {
1963
+ case ' ':
1964
+ case 'k':
1965
+ e.preventDefault();
1966
+ this.togglePlayPause();
1967
+ shortcutText = this.isPlaying ? 'Pause' : 'Play';
1968
+ break;
1969
+ case 'ArrowLeft':
1970
+ e.preventDefault();
1971
+ this.skip(-10);
1972
+ shortcutText = '-10s';
1973
+ break;
1974
+ case 'ArrowRight':
1975
+ e.preventDefault();
1976
+ this.skip(10);
1977
+ shortcutText = '+10s';
1978
+ break;
1979
+ case 'ArrowUp':
1980
+ e.preventDefault();
1981
+ this.changeVolume(0.1);
1982
+ if (this.isCasting && this.remotePlayer) {
1983
+ shortcutText = `Volume ${Math.round((this.remotePlayer.volumeLevel || 0) * 100)}%`;
1984
+ } else {
1985
+ shortcutText = `Volume ${Math.round(this.video.volume * 100)}%`;
1986
+ }
1987
+ break;
1988
+ case 'ArrowDown':
1989
+ e.preventDefault();
1990
+ this.changeVolume(-0.1);
1991
+ if (this.isCasting && this.remotePlayer) {
1992
+ shortcutText = `Volume ${Math.round((this.remotePlayer.volumeLevel || 0) * 100)}%`;
1993
+ } else {
1994
+ shortcutText = `Volume ${Math.round(this.video.volume * 100)}%`;
1995
+ }
1996
+ break;
1997
+ case 'm':
1998
+ e.preventDefault();
1999
+ this.toggleMute();
2000
+ if (this.isCasting && this.remotePlayer) {
2001
+ shortcutText = this.remotePlayer.isMuted ? 'Muted' : 'Unmuted';
2002
+ } else {
2003
+ shortcutText = this.video.muted ? 'Muted' : 'Unmuted';
2004
+ }
2005
+ break;
2006
+ case 'f':
2007
+ e.preventDefault();
2008
+ this.toggleFullscreen();
2009
+ shortcutText = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen';
2010
+ break;
2011
+ case 'p':
2012
+ e.preventDefault();
2013
+ this.togglePiP();
2014
+ shortcutText = 'Picture-in-Picture';
2015
+ break;
2016
+ case '0':
2017
+ case '1':
2018
+ case '2':
2019
+ case '3':
2020
+ case '4':
2021
+ case '5':
2022
+ case '6':
2023
+ case '7':
2024
+ case '8':
2025
+ case '9':
2026
+ e.preventDefault();
2027
+ const percent = parseInt(e.key) * 10;
2028
+ this.video.currentTime = (this.video.duration * percent) / 100;
2029
+ shortcutText = `${percent}%`;
2030
+ break;
2031
+ }
2032
+
2033
+ if (shortcutText) {
2034
+ this.showShortcutIndicator(shortcutText);
2035
+ }
2036
+ });
2037
+ }
2038
+
2039
+
2040
+ // Paywall modal setup and flow
2041
+ setupPaywallUI() {
2042
+ try {
2043
+ // Allow overriding userId/videoId via query params for testing
2044
+ try {
2045
+ const params = new URLSearchParams(window.location.search);
2046
+ const u = (params.get('user') || '').trim();
2047
+ const v = (params.get('video') || '').trim();
2048
+ if (u) this.demoRental.userId = u;
2049
+ if (v) this.demoRental.videoId = v;
2050
+ // Optional reset to revoke entitlement for testing
2051
+ const r = (params.get('reset') || '').toLowerCase();
2052
+ const doReset = r === '1' || r === 'true' || r === 'yes';
2053
+ if (doReset) {
2054
+ // Immediately treat as not entitled for this page load
2055
+ this.demoRental.entitled = false;
2056
+ this._previewGateHit = false;
2057
+ (async () => {
2058
+ try {
2059
+ await fetch(`${this.demoRental.apiBase}/api/rentals/mock/revoke`, {
2060
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2061
+ body: JSON.stringify({ userId: this.demoRental.userId, videoId: this.demoRental.videoId })
2062
+ });
2063
+ await this.checkDemoEntitlement(true);
2064
+ } catch (_) {}
2065
+ })();
2066
+ }
2067
+ } catch (_) {}
2068
+
2069
+ // Listen for checkout popup messages
2070
+ window.addEventListener('message', async (ev) => {
2071
+ const d = ev?.data || {};
2072
+ if (!d || d.type !== 'uvfCheckout') return;
2073
+ if (d.status === 'cancel') {
2074
+ try { if (this._checkoutWin && !this._checkoutWin.closed) this._checkoutWin.close(); } catch(_) {}
2075
+ this._checkoutWin = null;
2076
+ this.switchPaywallStep('gateway');
2077
+ }
2078
+ if (d.status === 'success') {
2079
+ try { if (this._checkoutWin && !this._checkoutWin.closed) this._checkoutWin.close(); } catch(_) {}
2080
+ this._checkoutWin = null;
2081
+ // If Stripe returned a sessionId, confirm it to issue entitlement without webhooks
2082
+ if (d.sessionId) {
2083
+ try {
2084
+ await fetch(`${this.demoRental.apiBase}/api/rentals/stripe/confirm`, {
2085
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2086
+ body: JSON.stringify({ sessionId: d.sessionId })
2087
+ });
2088
+ } catch(_) {}
2089
+ }
2090
+ // If Cashfree provided an orderId, verify and grant entitlement immediately
2091
+ if (d.orderId) {
2092
+ try {
2093
+ await fetch(`${this.demoRental.apiBase}/api/rentals/cashfree/verify?orderId=${encodeURIComponent(d.orderId)}&userId=${encodeURIComponent(this.demoRental.userId)}&videoId=${encodeURIComponent(this.demoRental.videoId)}`);
2094
+ } catch(_) {}
2095
+ }
2096
+ try { await this.checkDemoEntitlement(true); } catch(_){ }
2097
+ if (this.demoRental.entitled) {
2098
+ this._previewGateHit = false;
2099
+ this.hidePaywallOverlay();
2100
+ this.video.play().catch(()=>{});
2101
+ }
2102
+ }
2103
+ });
2104
+ // Initialize paywall header content from current metadata
2105
+ const mt = this.getPaywallMetadata();
2106
+ const tEl = document.getElementById('paywallTitle');
2107
+ const dEl = document.getElementById('paywallDesc');
2108
+ const iEl = document.getElementById('paywallThumb');
2109
+ if (tEl) tEl.textContent = mt.title || 'Continue watching';
2110
+ if (dEl) dEl.textContent = mt.description || 'Rent to continue watching this video.';
2111
+ if (iEl) {
2112
+ iEl.src = mt.thumbnail || '';
2113
+ iEl.style.display = mt.thumbnail ? 'block' : 'none';
2114
+ }
2115
+
2116
+ // Buttons
2117
+ const rentBtn = document.getElementById('rentNowBtn');
2118
+ const backIntroBtn = document.getElementById('backToIntroBtn');
2119
+ const backGatewaysBtn = document.getElementById('backToGatewaysBtn');
2120
+ const gwStripe = document.getElementById('gatewayStripe');
2121
+ const gwCashfree = document.getElementById('gatewayCashfree');
2122
+ const gwMock = document.getElementById('gatewayMock');
2123
+
2124
+ rentBtn?.addEventListener('click', () => this.switchPaywallStep('gateway'));
2125
+ backIntroBtn?.addEventListener('click', () => this.switchPaywallStep('intro'));
2126
+ backGatewaysBtn?.addEventListener('click', () => this.switchPaywallStep('gateway'));
2127
+ gwStripe?.addEventListener('click', () => this.openCheckoutPopup('stripe'));
2128
+ gwCashfree?.addEventListener('click', () => this.openCheckoutPopup('cashfree'));
2129
+ gwMock?.addEventListener('click', () => this.openCheckoutPopup('mock'));
2130
+ } catch (_) {}
2131
+ }
2132
+
2133
+ showPaywallOverlay(step = 'intro') {
2134
+ try {
2135
+ const o = document.getElementById('paywallOverlay');
2136
+ if (!o) return;
2137
+ this.switchPaywallStep(step);
2138
+ o.style.display = 'flex';
2139
+ o.classList.add('active');
2140
+ } catch (_) {}
2141
+ }
2142
+
2143
+ hidePaywallOverlay() {
2144
+ try {
2145
+ const o = document.getElementById('paywallOverlay');
2146
+ if (!o) return;
2147
+ o.classList.remove('active');
2148
+ o.style.display = 'none';
2149
+ } catch (_) {}
2150
+ }
2151
+
2152
+ switchPaywallStep(step) {
2153
+ try {
2154
+ const steps = ['intro','gateway','checkout'];
2155
+ for (const s of steps) {
2156
+ const el = document.getElementById(`paywallStep${s.charAt(0).toUpperCase()+s.slice(1)}`);
2157
+ if (el) el.classList.toggle('active', s === step);
2158
+ }
2159
+ // Refresh metadata
2160
+ if (step === 'intro' || step === 'gateway') {
2161
+ const mt = this.getPaywallMetadata();
2162
+ const tEl = document.getElementById('paywallTitle');
2163
+ const dEl = document.getElementById('paywallDesc');
2164
+ const iEl = document.getElementById('paywallThumb');
2165
+ if (tEl) tEl.textContent = mt.title || 'Continue watching';
2166
+ if (dEl) dEl.textContent = mt.description || 'Rent to continue watching this video.';
2167
+ if (iEl) {
2168
+ iEl.src = mt.thumbnail || '';
2169
+ iEl.style.display = mt.thumbnail ? 'block' : 'none';
2170
+ }
2171
+ }
2172
+ } catch (_) {}
2173
+ }
2174
+
2175
+ async beginCheckout(gateway) {
2176
+ // Deprecated in external-window flow; keep for compatibility if called
2177
+ return this.openCheckoutExternal(gateway);
2178
+ }
2179
+
2180
+ // Open checkout in dedicated popup window (not a new tab)
2181
+ async openCheckoutPopup(gateway) {
2182
+ try {
2183
+ if (!this.demoRental?.enabled) return;
2184
+ // If entitled already, close and continue
2185
+ try { await this.checkDemoEntitlement(); } catch(_) {}
2186
+ if (this.demoRental.entitled) { this.hidePaywallOverlay(); this.video.play().catch(()=>{}); return; }
2187
+
2188
+ // Attempt to size and center the popup
2189
+ const w = Math.min(window.screen.width - 100, 1000);
2190
+ const h = Math.min(window.screen.height - 100, 800);
2191
+ const left = Math.max(0, Math.round((window.screen.width - w) / 2));
2192
+ const top = Math.max(0, Math.round((window.screen.height - h) / 2));
2193
+
2194
+ if (gateway === 'mock') {
2195
+ try {
2196
+ await fetch(`${this.demoRental.apiBase}/api/rentals/mock/grant`, {
2197
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2198
+ body: JSON.stringify({ userId: this.demoRental.userId, videoId: this.demoRental.videoId })
2199
+ });
2200
+ await this.checkDemoEntitlement(true);
2201
+ if (this.demoRental.entitled) {
2202
+ this._previewGateHit = false;
2203
+ this.hidePaywallOverlay();
2204
+ this.video.play().catch(()=>{});
2205
+ }
2206
+ return;
2207
+ } catch (_) {
2208
+ this.showNotification('Mock grant failed');
2209
+ return;
2210
+ }
2211
+ }
2212
+
2213
+ if (gateway === 'stripe') {
2214
+ const res = await fetch(`${this.demoRental.apiBase}/api/rentals/stripe/checkout-session`, {
2215
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2216
+ body: JSON.stringify({
2217
+ userId: this.demoRental.userId,
2218
+ videoId: this.demoRental.videoId,
2219
+ successUrl: window.location.origin + window.location.pathname + '?rental=success&popup=1',
2220
+ cancelUrl: window.location.origin + window.location.pathname + '?rental=cancel&popup=1'
2221
+ })
2222
+ });
2223
+ const data = await res.json();
2224
+ if (data?.url) {
2225
+ try {
2226
+ this._checkoutWin && !this._checkoutWin.closed && this._checkoutWin.close();
2227
+ } catch(_) {}
2228
+ try {
2229
+ this._checkoutWin = window.open(data.url, 'uvfCheckout', `popup=1,width=${w},height=${h},left=${left},top=${top}`);
2230
+ } catch(_) { this._checkoutWin = null; }
2231
+ this.showNotification('Checkout opened');
2232
+ this.startEntitlementPolling();
2233
+ return;
2234
+ }
2235
+ this.showNotification('Failed to create checkout');
2236
+ return;
2237
+ }
2238
+
2239
+ if (gateway === 'cashfree') {
2240
+ // Try Cashfree JS SDK (Elements/Dropin) first
2241
+ const ensureSdk = async () => {
2242
+ if (window.Cashfree) return true;
2243
+ return await new Promise((resolve) => {
2244
+ const s = document.createElement('script');
2245
+ s.src = 'https://sdk.cashfree.com/js/v3/cashfree.js';
2246
+ s.async = true; s.defer = true;
2247
+ s.onload = () => resolve(true);
2248
+ s.onerror = () => resolve(false);
2249
+ document.head.appendChild(s);
2250
+ });
2251
+ };
2252
+
2253
+ const res = await fetch(`${this.demoRental.apiBase}/api/rentals/cashfree/order`, {
2254
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2255
+ body: JSON.stringify({ userId: this.demoRental.userId, videoId: this.demoRental.videoId, returnUrl: window.location.origin + window.location.pathname })
2256
+ });
2257
+ const data = await res.json();
2258
+ if (data?.orderId && (await ensureSdk()) && window.Cashfree) {
2259
+ this.switchPaywallStep('checkout');
2260
+ const cont = document.getElementById('cfDropin');
2261
+ const iframe = document.getElementById('paywallFrame');
2262
+ if (cont) cont.style.display = 'block';
2263
+ if (iframe) iframe.style.display = 'none';
2264
+ try {
2265
+ const mode = 'sandbox';
2266
+ const cashfree = window.Cashfree({ mode });
2267
+ if (cashfree && typeof cashfree.initialiseDropin === 'function') {
2268
+ cashfree.initialiseDropin(cont, {
2269
+ orderToken: data.sessionId || data.paymentSessionId || '',
2270
+ onSuccess: async (ev) => {
2271
+ try { await fetch(`${this.demoRental.apiBase}/api/rentals/cashfree/verify?orderId=${encodeURIComponent(data.orderId)}&userId=${encodeURIComponent(this.demoRental.userId)}&videoId=${encodeURIComponent(this.demoRental.videoId)}`); } catch(_) {}
2272
+ try { this.hidePaywallOverlay(); this.video.play().catch(()=>{}); } catch(_) {}
2273
+ },
2274
+ onFailure: (err) => {
2275
+ console.warn('[Cashfree Dropin] failure', err);
2276
+ this.showNotification('Checkout cancelled');
2277
+ this.switchPaywallStep('gateway');
2278
+ }
2279
+ });
2280
+ return;
2281
+ }
2282
+ } catch (e) { console.warn('Cashfree SDK error', e); }
2283
+ }
2284
+
2285
+ // Fallback: open hosted page in popup (may require allowing popups)
2286
+ const features = `popup=1,width=${w},height=${h},left=${left},top=${top}`;
2287
+ let pre = null;
2288
+ try { pre = window.open('', 'uvfCheckout', features); } catch(_) { pre = null; }
2289
+ if (data?.paymentLink) {
2290
+ try {
2291
+ this._checkoutWin && !this._checkoutWin.closed && this._checkoutWin.close();
2292
+ } catch(_) {}
2293
+ try {
2294
+ this._checkoutWin = pre && !pre.closed ? pre : window.open('', 'uvfCheckout', features);
2295
+ if (this._checkoutWin) this._checkoutWin.location.href = data.paymentLink;
2296
+ } catch(_) { this._checkoutWin = null; }
2297
+ this._cfOrderId = data.orderId;
2298
+ this.showNotification('Checkout opened');
2299
+ this.startEntitlementPolling();
2300
+ return;
2301
+ }
2302
+ try { pre && !pre.closed && pre.close(); } catch(_) {}
2303
+ this.showNotification('Failed to create order');
2304
+ return;
2305
+ }
2306
+ } catch (_) {
2307
+ this.showNotification('Checkout error');
2308
+ }
2309
+ }
2310
+
2311
+ // Show Rent Now button helper
2312
+ showRentButton() {
2313
+ try {
2314
+ const btn = document.getElementById('rentOpenBtn');
2315
+ if (btn) btn.style.display = 'inline-flex';
2316
+ } catch (_) {}
2317
+ }
2318
+
2319
+ // Enforce free preview gate (local or casting)
2320
+ enforceFreePreviewGate(current, fromSeek = false) {
2321
+ try {
2322
+ if (!this.demoRental?.enabled || this.demoRental.entitled) return;
2323
+ const lim = Number(this.demoRental.freePreviewSeconds || 0);
2324
+ if (!(lim > 0)) return;
2325
+ if (current >= lim - 0.01) {
2326
+ if (!this._previewGateHit) {
2327
+ this._previewGateHit = true;
2328
+ try { this.emit('onFreePreviewEnded'); } catch(_) {}
2329
+ }
2330
+ // Pause playback (remote or local) and clamp to just before the limit
2331
+ if (this.isCasting && this.remoteController) {
2332
+ try {
2333
+ if (this.remotePlayer && this.remotePlayer.isPaused === false) {
2334
+ this.remoteController.playOrPause();
2335
+ }
2336
+ } catch (_) {}
2337
+ } else {
2338
+ try { this.video.pause(); } catch(_) {}
2339
+ try {
2340
+ if (fromSeek || (this.video.currentTime > lim)) {
2341
+ this.video.currentTime = Math.max(0, lim - 0.1);
2342
+ }
2343
+ } catch (_) {}
2344
+ }
2345
+ this.showControls();
2346
+ this.showNotification('Free preview ended. Click Rent Now to continue.');
2347
+ }
2348
+ } catch (_) {}
2349
+ }
2350
+
2351
+ startEntitlementPolling() {
2352
+ try {
2353
+ this._pollEntTo && clearInterval(this._pollEntTo);
2354
+ let attempts = 0;
2355
+ this._pollEntTo = setInterval(async () => {
2356
+ attempts++;
2357
+ try { await this.checkDemoEntitlement(); } catch(_){ }
2358
+ // Close popup proactively if user clicked back
2359
+ if (this._checkoutWin && this._checkoutWin.closed && !this.demoRental.entitled) {
2360
+ this._checkoutWin = null;
2361
+ this.switchPaywallStep('gateway');
2362
+ }
2363
+ if (this.demoRental.entitled || attempts > 60) {
2364
+ clearInterval(this._pollEntTo);
2365
+ if (this.demoRental.entitled) {
2366
+ try { if (this._checkoutWin && !this._checkoutWin.closed) this._checkoutWin.close(); } catch(_) {}
2367
+ this._checkoutWin = null;
2368
+ this._previewGateHit = false;
2369
+ this.hidePaywallOverlay();
2370
+ this.video.play().catch(()=>{});
2371
+ this.showNotification('Unlocked');
2372
+ }
2373
+ }
2374
+ }, 3000);
2375
+ } catch (_) {}
2376
+ }
2377
+
2378
+ getPaywallMetadata() {
2379
+ try {
2380
+ const titleEl = document.getElementById('videoTitle');
2381
+ const descEl = document.getElementById('videoDescription');
2382
+ const title = (titleEl && titleEl.textContent) ? titleEl.textContent.trim() : '';
2383
+ const description = (descEl && descEl.textContent) ? descEl.textContent.trim() : '';
2384
+ let thumbnail = '';
2385
+ try { thumbnail = this.video?.poster || ''; } catch (_) {}
2386
+ if (!thumbnail) {
2387
+ thumbnail = '';
2388
+ }
2389
+ return { title, description, thumbnail };
2390
+ } catch (_) {
2391
+ return { title: '', description: '', thumbnail: '' };
2392
+ }
2393
+ }
2394
+
2395
+ async checkDemoEntitlement(showToast = false) {
2396
+ if (!this.demoRental?.enabled) return false;
2397
+ const url = `${this.demoRental.apiBase}/api/rentals/entitlement?userId=${encodeURIComponent(this.demoRental.userId)}&videoId=${encodeURIComponent(this.demoRental.videoId)}`;
2398
+ const res = await fetch(url);
2399
+ const data = await res.json();
2400
+ const prev = !!this.demoRental.entitled;
2401
+ this.demoRental.entitled = !!data?.entitled;
2402
+ if (this.demoRental.entitled && !prev) {
2403
+ // Reset gate when becoming entitled
2404
+ this._previewGateHit = false;
2405
+ }
2406
+ if (showToast) {
2407
+ this.showNotification(this.demoRental.entitled ? 'Entitled' : 'Not entitled');
2408
+ }
2409
+ // Toggle Rent Now button visibility
2410
+ try {
2411
+ const btn = document.getElementById('rentOpenBtn');
2412
+ if (btn) btn.style.display = this.demoRental.entitled ? 'none' : (this._previewGateHit ? 'inline-flex' : 'none');
2413
+ } catch(_) {}
2414
+ return this.demoRental.entitled;
2415
+ }
2416
+
2417
+
2418
+ showShortcutIndicator(text) {
2419
+ const el = this.shortcutIndicator;
2420
+ if (!el) return;
2421
+ try {
2422
+ const resetAnim = () => {
2423
+ el.classList.remove('active');
2424
+ // force reflow
2425
+ void el.offsetWidth;
2426
+ el.classList.add('active');
2427
+ };
2428
+ const setIcon = (svg) => {
2429
+ el.classList.add('ki-icon');
2430
+ el.innerHTML = `<div class="ki ki-icon">${svg}</div>`;
2431
+ resetAnim();
2432
+ };
2433
+ const setSkip = (dir, num) => {
2434
+ el.classList.add('ki-icon');
2435
+ const svg = dir === 'fwd'
2436
+ ? `<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>`
2437
+ : `<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>`;
2438
+ el.innerHTML = `<div class="ki ki-skip"><div class="ki-skip-num">${num}</div>${svg}</div>`;
2439
+ resetAnim();
2440
+ };
2441
+ const setVolume = (percent, muted = false) => {
2442
+ el.classList.remove('ki-icon');
2443
+ const p = Math.max(0, Math.min(100, Math.round(percent)));
2444
+ const icon = muted ? `
2445
+ <svg viewBox="0 0 24 24" aria-hidden="true">
2446
+ <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"/>
2447
+ </svg>` : `
2448
+ <svg viewBox="0 0 24 24" aria-hidden="true">
2449
+ <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"/>
2450
+ </svg>`;
2451
+ el.innerHTML = `
2452
+ <div class="ki ki-volume" role="status" aria-live="polite">
2453
+ <div class="ki-vol-icon">${icon}</div>
2454
+ <div class="ki-vol-bar"><div class="ki-vol-fill" style="width:${p}%"></div></div>
2455
+ <div class="ki-vol-text">${p}%</div>
2456
+ </div>`;
2457
+ resetAnim();
2458
+ };
2459
+ const setText = (t) => {
2460
+ el.classList.remove('ki-icon');
2461
+ el.innerHTML = `<div class="ki ki-text">${t}</div>`;
2462
+ resetAnim();
2463
+ };
2464
+
2465
+ // Map text cues (existing keyboard logic) to icon overlays
2466
+ if (text === 'Play') {
2467
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`);
2468
+ } else if (text === 'Pause') {
2469
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`);
2470
+ } else if (text === '+10s') {
2471
+ setSkip('fwd', 10);
2472
+ } else if (text === '-10s') {
2473
+ setSkip('back', 10);
2474
+ } else if (/^Volume\s+(\d+)%$/.test(text)) {
2475
+ const m = text.match(/^Volume\s+(\d+)%$/);
2476
+ const val = m ? parseInt(m[1], 10) : 0;
2477
+ setVolume(val);
2478
+ } else if (text === 'Muted' || text === 'Unmuted') {
2479
+ const muted = text === 'Muted';
2480
+ const level = (this.isCasting && this.remotePlayer) ? Math.round(((this.remotePlayer.volumeLevel || 0) * 100)) : Math.round((this.video?.volume || 0) * 100);
2481
+ setVolume(level, muted);
2482
+ } else if (text === 'Fullscreen') {
2483
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`);
2484
+ } else if (text === 'Exit Fullscreen') {
2485
+ setIcon(`<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`);
2486
+ } else if (text === 'Picture-in-Picture') {
2487
+ 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>`);
2488
+ } else if (/^\d+%$/.test(text)) {
2489
+ // Seek to percent
2490
+ setText(text);
2491
+ } else {
2492
+ // Fallback: textual toast (used by notifications)
2493
+ setText(text);
2494
+ }
2495
+
2496
+ // auto-hide after animation
2497
+ clearTimeout(this._kiTo);
2498
+ this._kiTo = setTimeout(() => {
2499
+ try { el.classList.remove('active'); } catch (_) {}
2500
+ }, 1000);
2501
+ } catch (err) {
2502
+ try {
2503
+ // Fallback to original text behavior
2504
+ el.textContent = String(text || '');
2505
+ el.classList.add('active');
2506
+ setTimeout(() => el.classList.remove('active'), 1000);
2507
+ } catch(_) {}
2508
+ }
2509
+ }
2510
+
2511
+ updateTimeTooltip(e) {
2512
+ const rect = this.progressBar.getBoundingClientRect();
2513
+ const percent = (e.clientX - rect.left) / rect.width;
2514
+ const total = this.isCasting ? (this.remotePlayer?.duration || 0) : this.video.duration;
2515
+ const time = percent * total;
2516
+
2517
+ this.timeTooltip.textContent = this.formatTime(time);
2518
+ this.timeTooltip.style.left = `${e.clientX - rect.left}px`;
2519
+ this.timeTooltip.style.opacity = '1';
2520
+ }
2521
+
2522
+ hideTimeTooltip() {
2523
+ this.timeTooltip.style.opacity = '0';
2524
+ }
2525
+
2526
+ setupWatermark() {
2527
+ const ctx = this.watermarkCanvas.getContext('2d');
2528
+
2529
+ const renderWatermark = () => {
2530
+ const container = this.watermarkCanvas.parentElement;
2531
+ this.watermarkCanvas.width = container.offsetWidth;
2532
+ this.watermarkCanvas.height = container.offsetHeight;
2533
+
2534
+ ctx.clearRect(0, 0, this.watermarkCanvas.width, this.watermarkCanvas.height);
2535
+
2536
+ // Gradient text effect using theme colors
2537
+ const wrapper = this.playerWrapper;
2538
+ const styles = getComputedStyle(wrapper);
2539
+ const c1 = (styles.getPropertyValue('--uvf-accent-1') || '#ff0000').trim();
2540
+ const c2 = (styles.getPropertyValue('--uvf-accent-2') || '#ff4d4f').trim();
2541
+ const gradient = ctx.createLinearGradient(0, 0, 200, 0);
2542
+ gradient.addColorStop(0, c1);
2543
+ gradient.addColorStop(1, c2);
2544
+
2545
+ ctx.save();
2546
+ ctx.globalAlpha = 0.3;
2547
+ ctx.font = '14px Arial';
2548
+ ctx.fillStyle = gradient;
2549
+ ctx.textAlign = 'left';
2550
+
2551
+ const text = `PREMIUM • ${new Date().toLocaleTimeString()}`;
2552
+ const x = 20 + Math.random() * (this.watermarkCanvas.width - 200);
2553
+ const y = 40 + Math.random() * (this.watermarkCanvas.height - 80);
2554
+
2555
+ ctx.fillText(text, x, y);
2556
+ ctx.restore();
2557
+ };
2558
+
2559
+ setInterval(renderWatermark, 5000);
2560
+ renderWatermark();
2561
+ }
2562
+
2563
+ // Theme API for demo
2564
+ setTheme(theme) {
2565
+ try {
2566
+ const w = this.playerWrapper;
2567
+ if (!w) return;
2568
+ let accent1 = null, accent2 = null, icon = null, t1 = null, t2 = null;
2569
+ if (typeof theme === 'string') {
2570
+ accent1 = theme;
2571
+ } else if (theme && typeof theme === 'object') {
2572
+ accent1 = theme.accent || null;
2573
+ accent2 = theme.accent2 || null;
2574
+ icon = theme.iconColor || null;
2575
+ t1 = theme.textPrimary || null;
2576
+ t2 = theme.textSecondary || null;
2577
+ }
2578
+ if (accent1) w.style.setProperty('--uvf-accent-1', accent1);
2579
+ if (!accent2 && accent1) {
2580
+ // Simple lighten fallback
2581
+ accent2 = accent1;
2582
+ }
2583
+ if (accent2) w.style.setProperty('--uvf-accent-2', accent2);
2584
+ if (accent1) w.style.setProperty('--uvf-accent-1-20', 'rgba(255,0,0,0.2)');
2585
+ if (icon) w.style.setProperty('--uvf-icon-color', icon);
2586
+ if (t1) w.style.setProperty('--uvf-text-primary', t1);
2587
+ if (t2) w.style.setProperty('--uvf-text-secondary', t2);
2588
+ } catch (_) {}
2589
+ }
2590
+
2591
+ // Metadata-driven Title/Description UI
2592
+ updateMetadataUI(md) {
2593
+ try {
2594
+ const bar = document.getElementById('titleBar');
2595
+ const titleEl = document.getElementById('videoTitle');
2596
+ const descEl = document.getElementById('videoDescription');
2597
+ const titleText = (md && md.title ? String(md.title) : '').trim();
2598
+ const descText = (md && md.description ? String(md.description) : '').trim();
2599
+ if (titleEl) {
2600
+ titleEl.textContent = titleText;
2601
+ titleEl.style.display = titleText ? 'block' : 'none';
2602
+ }
2603
+ if (descEl) {
2604
+ descEl.textContent = descText;
2605
+ descEl.style.display = descText ? 'block' : 'none';
2606
+ }
2607
+ if (bar) {
2608
+ const any = !!(titleText || descText);
2609
+ bar.style.display = any ? '' : 'none';
2610
+ }
2611
+ } catch (_) {}
2612
+ }
2613
+
2614
+ // Core loader with HLS/DASH/native detection
2615
+ async loadSource(input, options = {}) {
2616
+ // Accept URL string + options, or a VideoSource-like object
2617
+ let source = null;
2618
+ if (typeof input === 'string') {
2619
+ source = {
2620
+ url: input,
2621
+ type: options.type || 'auto',
2622
+ subtitles: Array.isArray(options.subtitles) ? options.subtitles : [],
2623
+ metadata: options.metadata || undefined,
2624
+ config: options.config || undefined
2625
+ };
2626
+ } else if (input && typeof input === 'object') {
2627
+ source = input;
2628
+ } else {
2629
+ throw new Error('Invalid source input');
2630
+ }
2631
+
2632
+ this.currentSourceUrl = source.url;
2633
+ this.emit('sourcechange', { url: source.url, source });
2634
+
2635
+ // Apply config to video
2636
+ const cfg = source.config || {};
2637
+ this.requestedConfig = {
2638
+ autoPlay: !!cfg.autoPlay,
2639
+ muted: cfg.muted,
2640
+ loop: cfg.loop,
2641
+ playsInline: cfg.playsInline,
2642
+ preload: cfg.preload,
2643
+ crossOrigin: cfg.crossOrigin,
2644
+ enableAdaptiveBitrate: (typeof cfg.enableAdaptiveBitrate === 'boolean') ? cfg.enableAdaptiveBitrate : true
2645
+ };
2646
+ this.autoPlayRequested = !!this.requestedConfig.autoPlay;
2647
+
2648
+ if (typeof this.requestedConfig.muted === 'boolean') this.video.muted = this.requestedConfig.muted;
2649
+ if (typeof this.requestedConfig.loop === 'boolean') this.video.loop = this.requestedConfig.loop;
2650
+ if (typeof this.requestedConfig.playsInline === 'boolean') this.video.playsInline = this.requestedConfig.playsInline;
2651
+ if (typeof this.requestedConfig.preload === 'string') this.video.preload = this.requestedConfig.preload;
2652
+ if (this.requestedConfig.crossOrigin) { try { this.video.crossOrigin = this.requestedConfig.crossOrigin; } catch (_) {} }
2653
+ if (source.metadata?.posterUrl) this.video.poster = source.metadata.posterUrl;
2654
+ // Update title/description UI from metadata (hide when missing)
2655
+ this.updateMetadataUI(source.metadata || null);
2656
+
2657
+ // Cleanup previous engine
2658
+ await this.cleanupEngines();
2659
+
2660
+ this.sourceType = this.detectSourceType(source.url, source.type);
2661
+ try {
2662
+ if (this.sourceType === 'hls') {
2663
+ await this.loadHLS(source.url);
2664
+ } else if (this.sourceType === 'dash') {
2665
+ await this.loadDASH(source.url);
2666
+ } else {
2667
+ await this.loadNative(source.url);
2668
+ }
2669
+ } catch (err) {
2670
+ console.error('Load error:', err);
2671
+ const errorObj = { type: 'load', details: err };
2672
+ this.emit('error', errorObj);
2673
+ this.emit('onError', errorObj);
2674
+ this.showNotification('Failed to load source');
2675
+ }
2676
+
2677
+ // Subtitles
2678
+ if (Array.isArray(source.subtitles) && source.subtitles.length > 0) {
2679
+ this.loadSubtitles(source.subtitles);
2680
+ } else {
2681
+ this.subtitleTracks = [];
2682
+ this.buildSubtitleOptions();
2683
+ }
2684
+ }
2685
+
2686
+ async cleanupEngines() {
2687
+ if (this.hls) {
2688
+ try { this.hls.destroy(); } catch (_) {}
2689
+ this.hls = null;
2690
+ }
2691
+ if (this.dash) {
2692
+ try { this.dash.reset(); } catch (_) {}
2693
+ this.dash = null;
2694
+ }
2695
+ // Clear native src to allow reattach
2696
+ if (this.video) {
2697
+ this.video.removeAttribute('src');
2698
+ this.video.load();
2699
+ }
2700
+ }
2701
+
2702
+ detectSourceType(url, forcedType) {
2703
+ if (forcedType && forcedType !== 'auto') return forcedType;
2704
+ const u = (url || '').toLowerCase();
2705
+ if (u.includes('.m3u8')) return 'hls';
2706
+ if (u.includes('.mpd')) return 'dash';
2707
+ if (u.includes('.mp4')) return 'mp4';
2708
+ if (u.includes('.webm')) return 'webm';
2709
+ return 'mp4';
2710
+ }
2711
+
2712
+ async loadHLS(url) {
2713
+ // Ensure Hls.js loaded
2714
+ if (!window.Hls) {
2715
+ await this.loadScript('https://cdn.jsdelivr.net/npm/hls.js@latest');
2716
+ }
2717
+ if (window.Hls && window.Hls.isSupported()) {
2718
+ this.hls = new window.Hls({
2719
+ enableWorker: true,
2720
+ lowLatencyMode: false,
2721
+ backBufferLength: 90
2722
+ });
2723
+ this.hls.loadSource(url);
2724
+ this.hls.attachMedia(this.video);
2725
+
2726
+ this.hls.on(window.Hls.Events.MANIFEST_PARSED, (evt, data) => {
2727
+ this.qualities = (data.levels || []).map((lvl, i) => ({
2728
+ height: lvl.height,
2729
+ width: lvl.width || 0,
2730
+ bitrate: lvl.bitrate,
2731
+ label: `${lvl.height}p`,
2732
+ index: i
2733
+ }));
2734
+ this.buildQualityOptions();
2735
+ // Respect enableAdaptiveBitrate config for HLS
2736
+ if (!this.requestedConfig.enableAdaptiveBitrate) {
2737
+ this.autoQuality = false;
2738
+ if (this.hls && this.qualities.length > 0) {
2739
+ this.currentQualityIndex = this.qualities.length - 1;
2740
+ this.hls.currentLevel = this.currentQualityIndex;
2741
+ this.highlightQualityOption();
2742
+ this.updateQualityBadge();
2743
+ }
2744
+ } else if (this.autoQuality) {
2745
+ this.setAutoQuality(true);
2746
+ }
2747
+ this.emit('ready');
2748
+ this.emit('onReady');
2749
+ if (this.autoPlayRequested) {
2750
+ this.video.play().catch(() => {});
2751
+ }
2752
+ });
2753
+
2754
+ this.hls.on(window.Hls.Events.LEVEL_SWITCHED, (evt, data) => {
2755
+ if (this.qualities[data.level]) {
2756
+ this.currentQualityIndex = data.level;
2757
+ this.updateQualityBadge();
2758
+ this.highlightQualityOption();
2759
+ const q = this.qualities[data.level];
2760
+ this.emit('qualitychange', q);
2761
+ this.emit('onQualityChanged', q);
2762
+ }
2763
+ });
2764
+
2765
+ this.hls.on(window.Hls.Events.ERROR, (evt, data) => {
2766
+ if (data.fatal) {
2767
+ this.handleHLSError(data);
2768
+ }
2769
+ });
2770
+ } else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
2771
+ this.video.src = url; // Safari native
2772
+ } else {
2773
+ throw new Error('HLS is not supported in this browser');
2774
+ }
2775
+ }
2776
+
2777
+ handleHLSError(data) {
2778
+ const Hls = window.Hls;
2779
+ switch (data.type) {
2780
+ case Hls.ErrorTypes.NETWORK_ERROR:
2781
+ console.warn('HLS fatal network error, restarting load');
2782
+ this.hls?.startLoad();
2783
+ break;
2784
+ case Hls.ErrorTypes.MEDIA_ERROR:
2785
+ console.warn('HLS fatal media error, recovering');
2786
+ this.hls?.recoverMediaError();
2787
+ break;
2788
+ default:
2789
+ console.error('HLS fatal error, destroying instance');
2790
+ const errorObj = { type: 'hls', details: data };
2791
+ this.emit('error', errorObj);
2792
+ this.emit('onError', errorObj);
2793
+ try { this.hls?.destroy(); } catch (_) {}
2794
+ this.hls = null;
2795
+ }
2796
+ }
2797
+
2798
+ async loadDASH(url) {
2799
+ if (!window.dashjs) {
2800
+ await this.loadScript('https://cdn.dashjs.org/latest/dash.all.min.js');
2801
+ }
2802
+ this.dash = window.dashjs.MediaPlayer().create();
2803
+ this.dash.initialize(this.video, url, false);
2804
+ this.dash.updateSettings({
2805
+ streaming: {
2806
+ abr: { autoSwitchBitrate: { video: (this.requestedConfig?.enableAdaptiveBitrate ?? true), audio: true } },
2807
+ buffer: { fastSwitchEnabled: true }
2808
+ }
2809
+ });
2810
+
2811
+ this.dash.on(window.dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, (e) => {
2812
+ if (e.mediaType === 'video') {
2813
+ const idx = (typeof e.newQuality !== 'undefined') ? e.newQuality : (typeof e.qualityIndex !== 'undefined' ? e.qualityIndex : (typeof this.dash.getQualityFor === 'function' ? this.dash.getQualityFor('video') : -1));
2814
+ if (typeof idx === 'number' && idx >= 0) {
2815
+ this.currentQualityIndex = idx;
2816
+ this.updateQualityBadge();
2817
+ this.highlightQualityOption();
2818
+ const q = this.qualities[this.currentQualityIndex] || null;
2819
+ this.emit('qualitychange', q);
2820
+ this.emit('onQualityChanged', q);
2821
+ }
2822
+ }
2823
+ });
2824
+
2825
+ this.dash.on(window.dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => {
2826
+ let list = [];
2827
+ try {
2828
+ if (typeof this.dash.getBitrateInfoListFor === 'function') {
2829
+ list = this.dash.getBitrateInfoListFor('video') || [];
2830
+ } else if (typeof this.dash.getBitrateInfoListForType === 'function') {
2831
+ list = this.dash.getBitrateInfoListForType('video') || [];
2832
+ } else if (typeof this.dash.getTracksFor === 'function') {
2833
+ const current = (typeof this.dash.getCurrentTrackFor === 'function') ? this.dash.getCurrentTrackFor('video') : null;
2834
+ const tracks = this.dash.getTracksFor('video') || [];
2835
+ const track = current || tracks[0];
2836
+ if (track && Array.isArray(track.bitrateList)) {
2837
+ list = track.bitrateList;
2838
+ }
2839
+ }
2840
+ } catch (err) {
2841
+ console.warn('Unable to query DASH bitrate list', err);
2842
+ }
2843
+ if (!Array.isArray(list)) list = [];
2844
+ this.qualities = list.map((info, i) => ({
2845
+ height: (typeof info.height === 'number') ? info.height : 0,
2846
+ width: (typeof info.width === 'number') ? info.width : 0,
2847
+ bitrate: (typeof info.bitrate === 'number') ? info.bitrate : (typeof info.bandwidth === 'number' ? info.bandwidth : 0),
2848
+ label: (info && (info.height || info.quality)) ? `${info.height || info.quality}p` : (info && (info.bitrate || info.bandwidth) ? `${Math.round(((info.bitrate || info.bandwidth)/1000))}kbps` : `Q${i}`),
2849
+ index: i
2850
+ }));
2851
+ this.buildQualityOptions();
2852
+ });
2853
+
2854
+ this.dash.on(window.dashjs.MediaPlayer.events.ERROR, (e) => {
2855
+ const errorObj = { type: 'dash', details: e };
2856
+ this.emit('error', errorObj);
2857
+ this.emit('onError', errorObj);
2858
+ });
2859
+ }
2860
+
2861
+ async loadNative(url) {
2862
+ this.video.src = url;
2863
+ this.video.load();
2864
+ // Clear qualities for native sources
2865
+ this.qualities = [];
2866
+ this.buildQualityOptions();
2867
+ this.emit('ready');
2868
+ }
2869
+
2870
+ loadScript(src) {
2871
+ return new Promise((resolve, reject) => {
2872
+ const s = document.createElement('script');
2873
+ s.src = src;
2874
+ s.onload = () => resolve();
2875
+ s.onerror = () => reject(new Error('Failed to load script: ' + src));
2876
+ document.head.appendChild(s);
2877
+ });
2878
+ }
2879
+
2880
+ togglePlayPause() {
2881
+ if (this.isCasting && this.remoteController) {
2882
+ try { this.remoteController.playOrPause(); } catch (_) {}
2883
+ return;
2884
+ }
2885
+ if (this.video.paused) {
2886
+ this.video.play();
2887
+ } else {
2888
+ this.video.pause();
2889
+ }
2890
+ }
2891
+
2892
+ onPlay() {
2893
+ this.isPlaying = true;
2894
+ document.getElementById('playIcon').style.display = 'none';
2895
+ document.getElementById('pauseIcon').style.display = 'block';
2896
+ this.centerPlayBtn.classList.add('hidden');
2897
+ this.emit('play');
2898
+ this.emit('onPlay');
2899
+
2900
+ // Schedule hide controls after play starts
2901
+ setTimeout(() => {
2902
+ if (this.isPlaying) {
2903
+ this.scheduleHideControls();
2904
+ }
2905
+ }, 1000);
2906
+ }
2907
+
2908
+ onPause() {
2909
+ this.isPlaying = false;
2910
+ document.getElementById('playIcon').style.display = 'block';
2911
+ document.getElementById('pauseIcon').style.display = 'none';
2912
+ this.centerPlayBtn.classList.remove('hidden');
2913
+ this.showControls();
2914
+ this.emit('pause');
2915
+ this.emit('onPause');
2916
+ }
2917
+
2918
+ onEnded() {
2919
+ this.onPause();
2920
+ this.video.currentTime = 0;
2921
+ this.emit('ended');
2922
+ this.emit('onEnded');
2923
+ }
2924
+
2925
+ onLoadedMetadata() {
2926
+ this.updateTimeDisplay();
2927
+ const meta = { duration: this.video.duration, width: this.video.videoWidth, height: this.video.videoHeight };
2928
+ this.emit('loadedmetadata', meta);
2929
+ this.emit('onLoadedMetadata', meta);
2930
+ }
2931
+
2932
+ updateProgress() {
2933
+ const percent = (this.video.currentTime / this.video.duration) * 100;
2934
+ this.progressFilled.style.width = percent + '%';
2935
+ this.progressHandle.style.left = percent + '%';
2936
+ this.updateTimeDisplay();
2937
+ this.emit('timeupdate', this.video.currentTime);
2938
+ this.emit('onTimeUpdate', this.video.currentTime);
2939
+ }
2940
+
2941
+ updateBuffered() {
2942
+ if (this.video.buffered.length > 0) {
2943
+ const buffered = (this.video.buffered.end(this.video.buffered.length - 1) / this.video.duration) * 100;
2944
+ this.progressBuffered.style.width = buffered + '%';
2945
+ this.emit('progress', buffered);
2946
+ this.emit('onProgress', buffered);
2947
+ }
2948
+ }
2949
+
2950
+ updateTimeDisplay() {
2951
+ const current = this.formatTime(this.video.currentTime);
2952
+ const duration = this.formatTime(this.video.duration);
2953
+ this.timeDisplay.textContent = `${current} / ${duration}`;
2954
+ }
2955
+
2956
+ formatTime(seconds) {
2957
+ if (!seconds || isNaN(seconds)) return '00:00';
2958
+
2959
+ const hours = Math.floor(seconds / 3600);
2960
+ const minutes = Math.floor((seconds % 3600) / 60);
2961
+ const secs = Math.floor(seconds % 60);
2962
+
2963
+ if (hours > 0) {
2964
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
2965
+ } else {
2966
+ return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
2967
+ }
2968
+ }
2969
+
2970
+ seek(e) {
2971
+ const rect = this.progressBar.getBoundingClientRect();
2972
+ const percent = (e.clientX - rect.left) / rect.width;
2973
+ const target = percent * (this.isCasting ? (this.remotePlayer?.duration || 0) : this.video.duration);
2974
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
2975
+ this.remotePlayer.currentTime = target;
2976
+ try { this.remoteController.seek(); } catch (_) {}
2977
+ } else {
2978
+ this.video.currentTime = target;
2979
+ }
2980
+ }
2981
+
2982
+ skip(seconds) {
2983
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
2984
+ const duration = this.remotePlayer.duration || 0;
2985
+ const cur = this.remotePlayer.currentTime || 0;
2986
+ const next = Math.max(0, Math.min(cur + seconds, duration));
2987
+ this.remotePlayer.currentTime = next;
2988
+ try { this.remoteController.seek(); } catch (_) {}
2989
+ } else {
2990
+ this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration));
2991
+ }
2992
+ }
2993
+
2994
+ toggleMute() {
2995
+ if (this.isCasting && this.remoteController) {
2996
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
2997
+ this.updateVolumeIcon();
2998
+ return;
2999
+ }
3000
+ this.video.muted = !this.video.muted;
3001
+ this.updateVolumeIcon();
3002
+ }
3003
+
3004
+ setVolume(e) {
3005
+ if (!this.volumeSlider) return;
3006
+
3007
+ const rect = this.volumeSlider.getBoundingClientRect();
3008
+ const x = e.clientX - rect.left;
3009
+ const width = rect.width;
3010
+ const percent = Math.max(0, Math.min(1, x / width));
3011
+
3012
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
3013
+ try {
3014
+ if (this.remotePlayer.isMuted) {
3015
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
3016
+ this.remotePlayer.isMuted = false;
3017
+ }
3018
+ this.remotePlayer.volumeLevel = percent;
3019
+ this.remoteController.setVolumeLevel();
3020
+ } catch (_) {}
3021
+ this.updateVolumeDisplay();
3022
+ } else {
3023
+ this.video.volume = percent;
3024
+ this.video.muted = false; // Unmute when adjusting volume
3025
+ this.updateVolumeDisplay();
3026
+ }
3027
+ }
3028
+
3029
+ changeVolume(delta) {
3030
+ if (this.isCasting && this.remoteController && this.remotePlayer) {
3031
+ const cur = this.remotePlayer.volumeLevel || 0;
3032
+ const next = Math.max(0, Math.min(1, cur + delta));
3033
+ try {
3034
+ if (this.remotePlayer.isMuted) {
3035
+ try { this.remoteController.muteOrUnmute(); } catch (_) {}
3036
+ this.remotePlayer.isMuted = false;
3037
+ }
3038
+ this.remotePlayer.volumeLevel = next;
3039
+ this.remoteController.setVolumeLevel();
3040
+ } catch (_) {}
3041
+ this.updateVolumeDisplay();
3042
+ } else {
3043
+ this.video.volume = Math.max(0, Math.min(1, this.video.volume + delta));
3044
+ this.updateVolumeDisplay();
3045
+ }
3046
+ }
3047
+
3048
+ updateVolumeDisplay() {
3049
+ if (this.isCasting && this.remotePlayer) {
3050
+ const percent = Math.round((this.remotePlayer.volumeLevel || 0) * 100);
3051
+ this.volumeFill.style.width = percent + '%';
3052
+ this.volumeValue.textContent = percent;
3053
+ } else {
3054
+ const percent = Math.round(this.video.volume * 100);
3055
+ this.volumeFill.style.width = percent + '%';
3056
+ this.volumeValue.textContent = percent;
3057
+ }
3058
+ this.updateVolumeIcon();
3059
+ }
3060
+
3061
+ updateVolumeIcon() {
3062
+ const volumeIcon = document.getElementById('volumeIcon');
3063
+ const muteIcon = document.getElementById('volumeMuteIcon');
3064
+
3065
+ let muted = false;
3066
+ let volume = 1;
3067
+ if (this.isCasting && this.remotePlayer) {
3068
+ muted = !!this.remotePlayer.isMuted;
3069
+ volume = this.remotePlayer.volumeLevel || 0;
3070
+ } else {
3071
+ muted = this.video.muted;
3072
+ volume = this.video.volume;
3073
+ }
3074
+ if (muted || volume === 0) {
3075
+ volumeIcon.style.display = 'none';
3076
+ muteIcon.style.display = 'block';
3077
+ } else {
3078
+ volumeIcon.style.display = 'block';
3079
+ muteIcon.style.display = 'none';
3080
+ }
3081
+ }
3082
+
3083
+ setSpeed(speed) {
3084
+ this.video.playbackRate = speed;
3085
+
3086
+ document.querySelectorAll('.speed-option').forEach(option => {
3087
+ option.classList.remove('active');
3088
+ if (parseFloat(option.dataset.speed) === speed) {
3089
+ option.classList.add('active');
3090
+ }
3091
+ });
3092
+
3093
+ this.settingsMenu.classList.remove('active');
3094
+ }
3095
+
3096
+ // Quality management
3097
+ buildQualityOptions() {
3098
+ if (!this.qualityOptionsEl) return;
3099
+ const wasAutoActive = this.autoQuality;
3100
+ const currentIdx = this.currentQualityIndex;
3101
+ this.qualityOptionsEl.innerHTML = '';
3102
+ const autoEl = document.createElement('div');
3103
+ autoEl.className = 'settings-option quality-option' + (wasAutoActive ? ' active' : '');
3104
+ autoEl.dataset.quality = 'auto';
3105
+ autoEl.textContent = 'Auto';
3106
+ this.qualityOptionsEl.appendChild(autoEl);
3107
+
3108
+ this.qualities.forEach((q, i) => {
3109
+ const el = document.createElement('div');
3110
+ el.className = 'settings-option quality-option' + (!wasAutoActive && i === currentIdx ? ' active' : '');
3111
+ el.dataset.index = String(i);
3112
+ el.textContent = q.label || `${q.height}p`;
3113
+ this.qualityOptionsEl.appendChild(el);
3114
+ });
3115
+ this.updateQualityBadge();
3116
+ }
3117
+
3118
+ setQualityIndex(index) {
3119
+ if (index < 0 || index >= this.qualities.length) return;
3120
+ this.autoQuality = false;
3121
+ if (this.hls) {
3122
+ this.hls.currentLevel = index;
3123
+ } else if (this.dash) {
3124
+ this.dash.setQualityFor('video', index);
3125
+ }
3126
+ this.currentQualityIndex = index;
3127
+ this.highlightQualityOption();
3128
+ this.updateQualityBadge();
3129
+ this.settingsMenu.classList.remove('active');
3130
+ }
3131
+
3132
+ setAutoQuality(enabled) {
3133
+ this.autoQuality = enabled;
3134
+ if (this.hls) {
3135
+ this.hls.currentLevel = enabled ? -1 : this.currentQualityIndex;
3136
+ } else if (this.dash) {
3137
+ this.dash.updateSettings({
3138
+ streaming: { abr: { autoSwitchBitrate: { video: enabled } } }
3139
+ });
3140
+ }
3141
+ this.highlightQualityOption();
3142
+ this.updateQualityBadge();
3143
+ this.settingsMenu.classList.remove('active');
3144
+ }
3145
+
3146
+ highlightQualityOption() {
3147
+ document.querySelectorAll('.quality-option').forEach(o => o.classList.remove('active'));
3148
+ if (this.autoQuality) {
3149
+ const autoEl = this.qualityOptionsEl?.querySelector('[data-quality="auto"]');
3150
+ if (autoEl) autoEl.classList.add('active');
3151
+ } else {
3152
+ const sel = this.qualityOptionsEl?.querySelector(`[data-index="${this.currentQualityIndex}"]`);
3153
+ if (sel) sel.classList.add('active');
3154
+ }
3155
+ }
3156
+
3157
+ updateQualityBadge() {
3158
+ const badge = document.getElementById('qualityBadge');
3159
+ if (!badge) return;
3160
+ if (this.autoQuality || this.currentQualityIndex < 0 || !this.qualities[this.currentQualityIndex]) {
3161
+ badge.textContent = 'AUTO';
3162
+ } else {
3163
+ const q = this.qualities[this.currentQualityIndex];
3164
+ badge.textContent = q.label || (q.height ? `${q.height}p` : 'AUTO');
3165
+ }
3166
+ }
3167
+
3168
+ async togglePiP() {
3169
+ try {
3170
+ if (document.pictureInPictureElement) {
3171
+ await document.exitPictureInPicture();
3172
+ } else if (this.video.requestPictureInPicture) {
3173
+ await this.video.requestPictureInPicture();
3174
+ }
3175
+ } catch (error) {
3176
+ console.error('PiP failed:', error);
3177
+ }
3178
+ }
3179
+
3180
+ toggleFullscreen() {
3181
+ const isFs = this.isFullscreen();
3182
+ if (!isFs) {
3183
+ const el = this.playerWrapper;
3184
+ try {
3185
+ let p = null;
3186
+ if (el.requestFullscreen) {
3187
+ p = el.requestFullscreen({ navigationUI: 'hide' }).catch?.(e => { throw e; });
3188
+ } else if (el.webkitRequestFullscreen) {
3189
+ p = el.webkitRequestFullscreen();
3190
+ } else if (el.msRequestFullscreen) {
3191
+ p = el.msRequestFullscreen();
3192
+ } else if (this.video && this.video.requestFullscreen) {
3193
+ p = this.video.requestFullscreen({ navigationUI: 'hide' }).catch?.(e => { throw e; });
3194
+ } else if (this.video && this.video.webkitEnterFullscreen) {
3195
+ // iOS Safari video-only fullscreen fallback
3196
+ this.video.webkitEnterFullscreen();
3197
+ this.syncFullscreenUI();
3198
+ return;
3199
+ } else {
3200
+ throw new Error('Fullscreen API not supported');
3201
+ }
3202
+ if (p && typeof p.then === 'function') {
3203
+ p.then(() => this.syncFullscreenUI()).catch(err => {
3204
+ console.error('Fullscreen failed:', err);
3205
+ this.showNotification('Fullscreen not allowed');
3206
+ this.syncFullscreenUI();
3207
+ });
3208
+ } else {
3209
+ this.syncFullscreenUI();
3210
+ }
3211
+ } catch (err) {
3212
+ console.error('Fullscreen failed:', err);
3213
+ this.showNotification('Fullscreen not allowed');
3214
+ this.syncFullscreenUI();
3215
+ }
3216
+ } else {
3217
+ try {
3218
+ let p = null;
3219
+ if (document.exitFullscreen) {
3220
+ p = document.exitFullscreen();
3221
+ } else if (document.webkitExitFullscreen) {
3222
+ p = document.webkitExitFullscreen();
3223
+ } else if (document.msExitFullscreen) {
3224
+ p = document.msExitFullscreen();
3225
+ } else if (this.video && this.video.webkitExitFullscreen) {
3226
+ this.video.webkitExitFullscreen();
3227
+ this.syncFullscreenUI();
3228
+ return;
3229
+ }
3230
+ if (p && typeof p.then === 'function') {
3231
+ p.then(() => this.syncFullscreenUI()).catch(err => {
3232
+ console.error('Exit fullscreen failed:', err);
3233
+ this.syncFullscreenUI();
3234
+ });
3235
+ } else {
3236
+ this.syncFullscreenUI();
3237
+ }
3238
+ } catch (err) {
3239
+ console.error('Exit fullscreen failed:', err);
3240
+ this.syncFullscreenUI();
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ isFullscreen() {
3246
+ return !!(document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement);
3247
+ }
3248
+
3249
+ syncFullscreenUI() {
3250
+ const isFs = this.isFullscreen();
3251
+ const enterIcon = document.getElementById('fullscreenIcon');
3252
+ const exitIcon = document.getElementById('fullscreenExitIcon');
3253
+ if (enterIcon && exitIcon) {
3254
+ enterIcon.style.display = isFs ? 'none' : 'block';
3255
+ exitIcon.style.display = isFs ? 'block' : 'none';
3256
+ }
3257
+ this.emit('fullscreenchange', isFs);
3258
+ this.emit('onFullscreenChanged', isFs);
3259
+ }
3260
+
3261
+ showControls() {
3262
+ clearTimeout(this.hideControlsTimeout);
3263
+ this.playerWrapper.classList.add('controls-visible');
3264
+ this.playerWrapper.classList.remove('no-cursor');
3265
+ this.playerWrapper.style.cursor = '';
3266
+ }
3267
+
3268
+ hideControls() {
3269
+ if (!this.isPlaying) return;
3270
+
3271
+ this.playerWrapper.classList.remove('controls-visible');
3272
+ this.playerWrapper.classList.add('no-cursor');
3273
+ this.playerWrapper.style.cursor = 'none';
3274
+ }
3275
+
3276
+ scheduleHideControls() {
3277
+ if (!this.isPlaying) return;
3278
+
3279
+ clearTimeout(this.hideControlsTimeout);
3280
+ this.hideControlsTimeout = setTimeout(() => {
3281
+ if (this.isPlaying && !this.controlsBar.matches(':hover')) {
3282
+ this.hideControls();
3283
+ }
3284
+ }, 3000);
3285
+ }
3286
+
3287
+ showLoading() {
3288
+ this.loadingContainer.classList.add('active');
3289
+ }
3290
+
3291
+ hideLoading() {
3292
+ this.loadingContainer.classList.remove('active');
3293
+ }
3294
+
3295
+ // Share functionality
3296
+ async shareVideo() {
3297
+ const titleEl = document.getElementById('videoTitle');
3298
+ const descEl = document.getElementById('videoDescription');
3299
+ const title = (titleEl?.textContent || '').trim();
3300
+ const text = (descEl?.textContent || '').trim();
3301
+ const shareData = { url: window.location.href };
3302
+ if (title) shareData.title = title;
3303
+ if (text) shareData.text = text;
3304
+
3305
+ try {
3306
+ if (navigator.share) {
3307
+ await navigator.share(shareData);
3308
+ } else {
3309
+ // Fallback: Copy to clipboard
3310
+ await navigator.clipboard.writeText(window.location.href);
3311
+ this.showNotification('Link copied to clipboard');
3312
+ }
3313
+ } catch (err) {
3314
+ console.log('Share failed:', err);
3315
+ }
3316
+ }
3317
+
3318
+ // Subtitle management
3319
+ loadSubtitles(tracks) {
3320
+ this.subtitleTracks = Array.isArray(tracks) ? tracks : [];
3321
+ // Ensure CORS for track fetches
3322
+ try { this.video.crossOrigin = 'anonymous'; } catch (_) {}
3323
+ // Remove existing track elements
3324
+ const existing = this.video.querySelectorAll('track');
3325
+ existing.forEach(t => t.remove());
3326
+ // Append new tracks
3327
+ this.subtitleTracks.forEach((t, i) => {
3328
+ const trackEl = document.createElement('track');
3329
+ trackEl.kind = t.kind || 'subtitles';
3330
+ trackEl.label = t.label || t.language || `Track ${i+1}`;
3331
+ trackEl.srclang = t.language || '';
3332
+ trackEl.src = t.url;
3333
+ if (t.default) trackEl.default = true;
3334
+ this.video.appendChild(trackEl);
3335
+ });
3336
+ // Build UI and select default
3337
+ this.buildSubtitleOptions();
3338
+ const def = this.subtitleTracks.find(t => t.default) || null;
3339
+ if (def) {
3340
+ this.selectSubtitle(def.label || def.language);
3341
+ } else {
3342
+ this.selectSubtitle('off');
3343
+ }
3344
+ }
3345
+
3346
+ buildSubtitleOptions() {
3347
+ if (!this.subtitleOptionsEl) return;
3348
+ this.subtitleOptionsEl.innerHTML = '';
3349
+ const offEl = document.createElement('div');
3350
+ offEl.className = 'settings-option subtitle-option' + (this.selectedSubtitle === 'off' ? ' active' : '');
3351
+ offEl.dataset.subtitle = 'off';
3352
+ offEl.textContent = 'Off';
3353
+ this.subtitleOptionsEl.appendChild(offEl);
3354
+ this.subtitleTracks.forEach((t) => {
3355
+ const key = t.label || t.language;
3356
+ const el = document.createElement('div');
3357
+ el.className = 'settings-option subtitle-option' + (this.selectedSubtitle === key ? ' active' : '');
3358
+ el.dataset.subtitle = key;
3359
+ el.textContent = key;
3360
+ this.subtitleOptionsEl.appendChild(el);
3361
+ });
3362
+ }
3363
+
3364
+ selectSubtitle(key) {
3365
+ this.selectedSubtitle = key;
3366
+ // Update local HTML5 tracks for non-cast playback
3367
+ const tracks = this.video.textTracks;
3368
+ for (let i = 0; i < tracks.length; i++) {
3369
+ const tt = tracks[i];
3370
+ if (key === 'off') {
3371
+ tt.mode = 'disabled';
3372
+ } else {
3373
+ tt.mode = (tt.label === key) ? 'showing' : 'disabled';
3374
+ }
3375
+ }
3376
+ // Update UI active state
3377
+ document.querySelectorAll('.subtitle-option').forEach(o => o.classList.remove('active'));
3378
+ const sel = this.subtitleOptionsEl?.querySelector(`[data-subtitle="${CSS.escape(key)}"]`);
3379
+ if (sel) sel.classList.add('active');
3380
+ this.settingsMenu.classList.remove('active');
3381
+ this.emit('subtitlechange', key);
3382
+ // If casting, update active tracks on receiver
3383
+ try { if (this.isCasting) { this._updateCastActiveTracks(); } } catch (_) {}
3384
+ }
3385
+
3386
+ _updateCastActiveTracks() {
3387
+ try {
3388
+ if (!(window.cast && cast.framework)) return;
3389
+ const session = cast.framework.CastContext.getInstance().getCurrentSession();
3390
+ if (!session) return;
3391
+ const media = session.getMediaSession && session.getMediaSession();
3392
+ if (!media) return;
3393
+ let ids = [];
3394
+ if (this.selectedSubtitle && this.selectedSubtitle !== 'off') {
3395
+ const tid = this._castTrackIdByKey ? this._castTrackIdByKey[this.selectedSubtitle] : null;
3396
+ if (tid) ids = [tid];
3397
+ }
3398
+ __dbg?.log && __dbg.log('Setting active cast tracks', ids);
3399
+ if (typeof media.setActiveTracks === 'function') {
3400
+ media.setActiveTracks(ids, () => { __dbg?.log && __dbg.log('Active tracks set'); }, (e) => { __dbg?.warn && __dbg.warn('setActiveTracks error', e); });
3401
+ } else if (typeof media.setActiveTrackIds === 'function') {
3402
+ media.setActiveTrackIds(ids);
3403
+ }
3404
+ } catch (err) {
3405
+ try { __dbg?.warn && __dbg.warn('Failed to set active cast tracks', err); } catch(_) {}
3406
+ }
3407
+ }
3408
+
3409
+ stopCasting() {
3410
+ try {
3411
+ if (!(window.cast && cast.framework)) { this.showNotification('Cast not ready'); return; }
3412
+ const ctx = cast.framework.CastContext.getInstance();
3413
+ const sess = ctx.getCurrentSession && ctx.getCurrentSession();
3414
+ if (sess) {
3415
+ __dbg?.log && __dbg.log('Ending cast session');
3416
+ try { sess.endSession(true); } catch (e) { try { __dbg?.warn && __dbg.warn('endSession error', e); } catch(_) {} }
3417
+ this.disableCastRemoteControl();
3418
+ this.showNotification('Stopped casting');
3419
+ } else {
3420
+ this.showNotification('Not casting');
3421
+ }
3422
+ } catch (err) {
3423
+ try { __dbg?.warn && __dbg.warn('Stop casting failed', err); } catch (_) {}
3424
+ } finally {
3425
+ try { this._syncCastButtons(); } catch (_) {}
3426
+ }
3427
+ }
3428
+
3429
+ _syncCastButtons() {
3430
+ const stopBtn = document.getElementById('stopCastBtn');
3431
+ if (stopBtn) stopBtn.style.display = this.isCasting ? 'inline-flex' : 'none';
3432
+ const castBtn = document.getElementById('castBtn');
3433
+ if (castBtn) {
3434
+ if (this.isCasting) {
3435
+ castBtn.classList.add('cast-grey');
3436
+ // Update tooltip to Pick device (+ current device)
3437
+ let title = 'Pick device';
3438
+ try {
3439
+ if (window.cast && cast.framework) {
3440
+ const sess = cast.framework.CastContext.getInstance().getCurrentSession();
3441
+ const dev = sess && typeof sess.getCastDevice === 'function' ? sess.getCastDevice() : null;
3442
+ if (dev && dev.friendlyName) title += ` (${dev.friendlyName})`;
3443
+ }
3444
+ } catch (_) {}
3445
+ castBtn.title = title;
3446
+ castBtn.setAttribute('aria-label', title);
3447
+ } else {
3448
+ castBtn.classList.remove('cast-grey');
3449
+ castBtn.title = 'Cast';
3450
+ castBtn.setAttribute('aria-label', 'Cast');
3451
+ }
3452
+ }
3453
+ if (this.playerWrapper) {
3454
+ if (this.isCasting) this.playerWrapper.classList.add('casting');
3455
+ else this.playerWrapper.classList.remove('casting');
3456
+ }
3457
+ }
3458
+
3459
+ onCastButtonClick() {
3460
+ try {
3461
+ if (this.isCasting && window.cast && cast.framework) {
3462
+ __dbg?.log && __dbg.log('Cast button (casting): opening device picker');
3463
+ const ctx = cast.framework.CastContext.getInstance();
3464
+ // Show device picker to transfer/choose device
3465
+ ctx.requestSession().catch(() => {});
3466
+ return;
3467
+ }
3468
+ } catch (_) {}
3469
+ // Not casting yet: start casting via helper
3470
+ window.initCastFrameworkForPlayer?.(this);
3471
+ }
3472
+
3473
+ // Show notification
3474
+ showNotification(message) {
3475
+ // Use the shortcut indicator for notifications
3476
+ this.shortcutIndicator.textContent = message;
3477
+ this.shortcutIndicator.classList.add('active');
3478
+ setTimeout(() => {
3479
+ this.shortcutIndicator.classList.remove('active');
3480
+ }, 2000);
3481
+ }
3482
+ }
3483
+
3484
+ // Initialize player when DOM is ready
3485
+ document.addEventListener('DOMContentLoaded', () => {
3486
+ const p = new EnhancedVideoPlayer();
3487
+ // Attach a simple logger for both onX and base event names
3488
+ const attachLogger = (player) => {
3489
+ const events = [
3490
+ 'onReady','onPlay','onPause','onEnded','onTimeUpdate','onBuffering','onError',
3491
+ 'onQualityChanged','onVolumeChanged','onFullscreenChanged','onProgress','onSeeking',
3492
+ 'onSeeked','onLoadedMetadata',
3493
+ 'play','pause','ended','timeupdate','progress','seeking','seeked','loadedmetadata',
3494
+ 'fullscreenchange','qualitychange','volumechange','subtitlechange','sourcechange'
3495
+ ];
3496
+ events.forEach((ev) => {
3497
+ try {
3498
+ player.on(ev, (...args) => {
3499
+ try {
3500
+ console.log('[EnhancedPlayer]', ev, ...args);
3501
+ } catch (_) {}
3502
+ });
3503
+ } catch (_) {}
3504
+ });
3505
+ };
3506
+ const params = new URLSearchParams(window.location.search);
3507
+ const dbg = (params.get('debug') || '').toLowerCase();
3508
+ if (dbg === '1' || dbg === 'true') {
3509
+ console.log('[EnhancedPlayer] debug logger enabled');
3510
+ attachLogger(p);
3511
+ }
3512
+ if (dbg === '2' || dbg.includes('cast')) {
3513
+ console.log('[EnhancedPlayer] cast debug instrumentation enabled');
3514
+ (function patchDebugPlayer(player){
3515
+ if (!player) return;
3516
+ const wrap = (name) => {
3517
+ if (typeof player[name] !== 'function') return;
3518
+ const orig = player[name].bind(player);
3519
+ player[name] = function(){
3520
+ try { __dbg?.log && __dbg.log(`[DBG] ${name}`, { args: Array.from(arguments), isCasting: this.isCasting }); } catch(_) {}
3521
+ try { return orig.apply(this, arguments); } catch (err) { try { __dbg?.error && __dbg.error(`[DBG] ${name} error`, err); } catch(_) {} throw err; }
3522
+ };
3523
+ };
3524
+ ['togglePlayPause','seek','skip','toggleMute','setVolume','changeVolume'].forEach(wrap);
3525
+ const wrap2 = (name, after) => {
3526
+ if (typeof player[name] !== 'function') return;
3527
+ const orig = player[name].bind(player);
3528
+ player[name] = function(){
3529
+ try { __dbg?.log && __dbg.log(`[DBG] ${name} called`); } catch(_) {}
3530
+ const res = orig.apply(this, arguments);
3531
+ try { after && after.call(this); } catch(_) {}
3532
+ return res;
3533
+ };
3534
+ };
3535
+ wrap2('enableCastRemoteControl', function(){
3536
+ try {
3537
+ if (this.remoteController && this.remotePlayer && window.cast && cast.framework) {
3538
+ const rc = this.remoteController; const rp = this.remotePlayer; const RPET = cast.framework.RemotePlayerEventType;
3539
+ const add = (type, lab, getter) => rc.addEventListener(type, () => { try { __dbg?.log && __dbg.log(`[EVT] ${lab}`, getter ? getter() : undefined); } catch(_) {} });
3540
+ add(RPET.IS_PAUSED_CHANGED, 'IS_PAUSED_CHANGED', () => ({ isPaused: rp.isPaused }));
3541
+ add(RPET.CURRENT_TIME_CHANGED, 'CURRENT_TIME_CHANGED', () => ({ currentTime: rp.currentTime, duration: rp.duration }));
3542
+ add(RPET.DURATION_CHANGED, 'DURATION_CHANGED', () => ({ duration: rp.duration }));
3543
+ add(RPET.PLAYER_STATE_CHANGED, 'PLAYER_STATE_CHANGED', () => ({ state: rp.playerState }));
3544
+ add(RPET.IS_MUTED_CHANGED, 'IS_MUTED_CHANGED', () => ({ isMuted: rp.isMuted }));
3545
+ add(RPET.VOLUME_LEVEL_CHANGED, 'VOLUME_LEVEL_CHANGED', () => ({ volumeLevel: rp.volumeLevel }));
3546
+ add(RPET.IS_CONNECTED_CHANGED, 'IS_CONNECTED_CHANGED', () => ({ isConnected: rp.isConnected }));
3547
+ }
3548
+ } catch(_) {}
3549
+ });
3550
+ wrap2('disableCastRemoteControl');
3551
+ })(p);
3552
+ }
3553
+ });
3554
+ </script>
3555
+ </body>
3556
+ </html>