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