@goweekdays/layer-common 1.0.4 → 1.0.6

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.
@@ -0,0 +1,1496 @@
1
+ <template>
2
+ <div
3
+ ref="playerContainer"
4
+ class="video-player-container"
5
+ :class="{ fullscreen: isFullscreen }"
6
+ :style="{ height: typeof height === 'number' ? height + 'px' : height }"
7
+ @mousemove="handleMouseMove"
8
+ @mouseleave="hideControls"
9
+ >
10
+ <!-- Video Element -->
11
+ <video
12
+ ref="videoElement"
13
+ class="video-element"
14
+ :src="shouldLoadVideo ? src : ''"
15
+ :poster="poster"
16
+ :preload="
17
+ shouldLoadVideo
18
+ ? props.preload
19
+ : shouldPreloadFrames
20
+ ? 'metadata'
21
+ : 'none'
22
+ "
23
+ @loadedmetadata="onVideoLoaded"
24
+ @timeupdate="onTimeUpdate"
25
+ @progress="onProgress"
26
+ @ended="onVideoEnded"
27
+ @play="isPlaying = true"
28
+ @pause="isPlaying = false"
29
+ @click="togglePlayPause"
30
+ >
31
+ Your browser does not support the video tag.
32
+ </video>
33
+
34
+ <!-- Loading Spinner -->
35
+ <div v-if="isLoading && videoLoaded" class="loading-spinner">
36
+ <div class="spinner"></div>
37
+ </div>
38
+
39
+ <!-- Play/Pause Overlay Button -->
40
+ <div
41
+ v-if="!isPlaying && (!videoLoaded || !isLoading)"
42
+ class="play-overlay"
43
+ @click="togglePlayPause"
44
+ >
45
+ <button class="play-button-large">
46
+ <svg viewBox="0 0 24 24" fill="currentColor">
47
+ <path d="M8 5v14l11-7z" />
48
+ </svg>
49
+ </button>
50
+ </div>
51
+
52
+ <!-- Controls -->
53
+ <div
54
+ class="controls-container"
55
+ :class="{ visible: controlsVisible, 'always-visible': !isPlaying }"
56
+ >
57
+ <!-- Progress Bar -->
58
+ <div class="progress-container">
59
+ <div
60
+ class="progress-bar"
61
+ @click="seek"
62
+ @mousedown="startSeeking"
63
+ @mousemove="onProgressHover"
64
+ @mouseleave="hideProgressPreview"
65
+ >
66
+ <div class="progress-background"></div>
67
+ <div
68
+ class="progress-buffered"
69
+ :style="{ width: bufferedPercentage + '%' }"
70
+ ></div>
71
+ <div
72
+ class="progress-played"
73
+ :style="{ width: playedPercentage + '%' }"
74
+ ></div>
75
+ <div
76
+ class="progress-handle"
77
+ :style="{ left: playedPercentage + '%' }"
78
+ ></div>
79
+
80
+ <!-- Progress Preview -->
81
+ <div
82
+ v-if="progressPreview.visible"
83
+ class="progress-preview"
84
+ :style="{ left: progressPreview.x + 'px' }"
85
+ >
86
+ {{ formatTime(progressPreview.time) }}
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- Control Buttons -->
92
+ <div class="controls-row">
93
+ <div class="controls-left">
94
+ <!-- Play/Pause -->
95
+ <button
96
+ class="control-button"
97
+ @click="togglePlayPause"
98
+ :title="isPlaying ? 'Pause' : 'Play'"
99
+ >
100
+ <svg v-if="!isPlaying" viewBox="0 0 24 24" fill="currentColor">
101
+ <path d="M8 5v14l11-7z" />
102
+ </svg>
103
+ <svg v-else viewBox="0 0 24 24" fill="currentColor">
104
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
105
+ </svg>
106
+ </button>
107
+
108
+ <!-- Next Video (if available) -->
109
+ <button
110
+ v-if="hasNext"
111
+ class="control-button"
112
+ @click="$emit('next')"
113
+ title="Next video"
114
+ >
115
+ <svg viewBox="0 0 24 24" fill="currentColor">
116
+ <path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
117
+ </svg>
118
+ </button>
119
+
120
+ <!-- Volume Controls -->
121
+ <div class="volume-container">
122
+ <button
123
+ class="control-button volume-button"
124
+ @click="toggleMute"
125
+ :title="isMuted ? 'Unmute' : 'Mute'"
126
+ >
127
+ <svg
128
+ v-if="isMuted || volume === 0"
129
+ viewBox="0 0 24 24"
130
+ fill="currentColor"
131
+ >
132
+ <path
133
+ 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"
134
+ />
135
+ </svg>
136
+ <svg
137
+ v-else-if="volume < 0.5"
138
+ viewBox="0 0 24 24"
139
+ fill="currentColor"
140
+ >
141
+ <path
142
+ d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"
143
+ />
144
+ </svg>
145
+ <svg v-else viewBox="0 0 24 24" fill="currentColor">
146
+ <path
147
+ 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"
148
+ />
149
+ </svg>
150
+ </button>
151
+
152
+ <div
153
+ class="volume-slider-container"
154
+ :class="{ visible: volumeSliderVisible }"
155
+ >
156
+ <input
157
+ type="range"
158
+ class="volume-slider"
159
+ min="0"
160
+ max="1"
161
+ step="0.1"
162
+ v-model="volume"
163
+ @input="setVolume"
164
+ />
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Time Display -->
169
+ <div class="time-display">
170
+ {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
171
+ </div>
172
+ </div>
173
+
174
+ <div class="controls-right">
175
+ <!-- Playback Speed -->
176
+ <div class="speed-container" :class="{ active: speedMenuVisible }">
177
+ <button
178
+ class="control-button speed-button"
179
+ @click="toggleSpeedMenu"
180
+ title="Playback speed"
181
+ >
182
+ {{ playbackRate }}x
183
+ </button>
184
+ <div class="speed-menu" :class="{ visible: speedMenuVisible }">
185
+ <div
186
+ v-for="speed in speedOptions"
187
+ :key="speed"
188
+ class="speed-option"
189
+ :class="{ active: playbackRate === speed }"
190
+ @click="setPlaybackSpeed(speed)"
191
+ >
192
+ {{ speed === 1 ? "Normal" : speed + "x" }}
193
+ </div>
194
+ </div>
195
+ </div>
196
+
197
+ <!-- Settings -->
198
+ <div
199
+ class="settings-container"
200
+ :class="{ active: settingsMenuVisible }"
201
+ >
202
+ <button
203
+ class="control-button"
204
+ @click="toggleSettingsMenu"
205
+ title="Settings"
206
+ >
207
+ <svg viewBox="0 0 24 24" fill="currentColor">
208
+ <path
209
+ 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.82,11.69,4.82,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"
210
+ />
211
+ </svg>
212
+ </button>
213
+
214
+ <div
215
+ class="settings-menu"
216
+ :class="{ visible: settingsMenuVisible }"
217
+ >
218
+ <div class="settings-section">
219
+ <h3>Keyboard Shortcuts</h3>
220
+ <div class="shortcuts-list">
221
+ <div class="shortcut-item">
222
+ <span class="shortcut-key">Ctrl + Space / Ctrl + K</span>
223
+ <span class="shortcut-desc">Play/Pause</span>
224
+ </div>
225
+ <div class="shortcut-item">
226
+ <span class="shortcut-key">Ctrl + F</span>
227
+ <span class="shortcut-desc">Toggle Fullscreen</span>
228
+ </div>
229
+ <div class="shortcut-item">
230
+ <span class="shortcut-key">Ctrl + M</span>
231
+ <span class="shortcut-desc">Toggle Mute</span>
232
+ </div>
233
+ <div class="shortcut-item">
234
+ <span class="shortcut-key">Ctrl + ←/→</span>
235
+ <span class="shortcut-desc">Skip 10s backward/forward</span>
236
+ </div>
237
+ <div class="shortcut-item">
238
+ <span class="shortcut-key">Shift + ←/→</span>
239
+ <span class="shortcut-desc">Skip 30s backward/forward</span>
240
+ </div>
241
+ <div class="shortcut-item">
242
+ <span class="shortcut-key">Ctrl + ↑/↓</span>
243
+ <span class="shortcut-desc">Volume up/down (10%)</span>
244
+ </div>
245
+ <div class="shortcut-item">
246
+ <span class="shortcut-key">Shift + ↑/↓</span>
247
+ <span class="shortcut-desc">Volume up/down (5%)</span>
248
+ </div>
249
+ <div class="shortcut-item">
250
+ <span class="shortcut-key">Ctrl + +/-</span>
251
+ <span class="shortcut-desc">Speed up/down</span>
252
+ </div>
253
+ <div class="shortcut-item">
254
+ <span class="shortcut-key">Ctrl + 0</span>
255
+ <span class="shortcut-desc">Reset speed to normal</span>
256
+ </div>
257
+ <div class="shortcut-item">
258
+ <span class="shortcut-key">Alt + P</span>
259
+ <span class="shortcut-desc">Picture in Picture</span>
260
+ </div>
261
+ <div class="shortcut-item" v-if="hasNext">
262
+ <span class="shortcut-key">Ctrl + N</span>
263
+ <span class="shortcut-desc">Next video</span>
264
+ </div>
265
+ <div class="shortcut-item">
266
+ <span class="shortcut-key">Ctrl + H</span>
267
+ <span class="shortcut-desc">Show/Hide this help</span>
268
+ </div>
269
+ <div class="shortcut-item">
270
+ <span class="shortcut-key">ESC</span>
271
+ <span class="shortcut-desc">Close menus</span>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <!-- Picture in Picture -->
279
+ <button
280
+ v-if="supportsPiP"
281
+ class="control-button"
282
+ @click="togglePictureInPicture"
283
+ title="Picture in picture"
284
+ >
285
+ <svg viewBox="0 0 24 24" fill="currentColor">
286
+ <path
287
+ 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"
288
+ />
289
+ </svg>
290
+ </button>
291
+
292
+ <!-- Fullscreen -->
293
+ <button
294
+ class="control-button"
295
+ @click="toggleFullscreen"
296
+ :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'"
297
+ >
298
+ <svg v-if="!isFullscreen" viewBox="0 0 24 24" fill="currentColor">
299
+ <path
300
+ d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
301
+ />
302
+ </svg>
303
+ <svg v-else viewBox="0 0 24 24" fill="currentColor">
304
+ <path
305
+ d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
306
+ />
307
+ </svg>
308
+ </button>
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </template>
314
+
315
+ <script setup>
316
+ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
317
+
318
+ // Props
319
+ const props = defineProps({
320
+ src: {
321
+ type: String,
322
+ required: true,
323
+ },
324
+ poster: {
325
+ type: String,
326
+ default: "",
327
+ },
328
+ autoplay: {
329
+ type: Boolean,
330
+ default: false,
331
+ },
332
+ hasNext: {
333
+ type: Boolean,
334
+ default: false,
335
+ },
336
+ lazy: {
337
+ type: Boolean,
338
+ default: true,
339
+ },
340
+ preload: {
341
+ type: String,
342
+ default: "metadata", // 'none', 'metadata', 'auto'
343
+ validator: (value) => ["none", "metadata", "auto"].includes(value),
344
+ },
345
+ height: {
346
+ type: [String, Number],
347
+ default: "auto",
348
+ },
349
+ });
350
+
351
+ // Emits
352
+ const emit = defineEmits(["next", "timeupdate", "ended"]);
353
+
354
+ // Refs
355
+ const playerContainer = ref(null);
356
+ const videoElement = ref(null);
357
+ const intersectionObserver = ref(null);
358
+
359
+ // State
360
+ const isPlaying = ref(false);
361
+ const isLoading = ref(false);
362
+ const videoLoaded = ref(false);
363
+ const currentTime = ref(0);
364
+ const duration = ref(0);
365
+ const bufferedTime = ref(0);
366
+ const volume = ref(1);
367
+ const isMuted = ref(false);
368
+ const isFullscreen = ref(false);
369
+ const controlsVisible = ref(true);
370
+ const controlsTimeout = ref(null);
371
+ const volumeSliderVisible = ref(false);
372
+ const speedMenuVisible = ref(false);
373
+ const settingsMenuVisible = ref(false);
374
+ const playbackRate = ref(1);
375
+ const isSeeking = ref(false);
376
+ const supportsPiP = ref(false);
377
+ const inViewport = ref(false);
378
+ const shouldPreloadFrames = ref(false);
379
+
380
+ // Progress preview
381
+ const progressPreview = ref({
382
+ visible: false,
383
+ x: 0,
384
+ time: 0,
385
+ });
386
+
387
+ // Speed options
388
+ const speedOptions = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
389
+
390
+ // Computed
391
+ const playedPercentage = computed(() => {
392
+ return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0;
393
+ });
394
+
395
+ const bufferedPercentage = computed(() => {
396
+ return duration.value > 0 ? (bufferedTime.value / duration.value) * 100 : 0;
397
+ });
398
+
399
+ const shouldLoadVideo = computed(() => {
400
+ return (
401
+ !props.lazy ||
402
+ videoLoaded.value ||
403
+ props.autoplay ||
404
+ shouldPreloadFrames.value
405
+ );
406
+ });
407
+
408
+ // Methods
409
+ const togglePlayPause = () => {
410
+ if (!videoElement.value) return;
411
+
412
+ // Load video if not already loaded
413
+ if (!videoLoaded.value) {
414
+ loadVideo();
415
+ return;
416
+ }
417
+
418
+ if (isPlaying.value) {
419
+ videoElement.value.pause();
420
+ } else {
421
+ videoElement.value.play();
422
+ }
423
+ };
424
+
425
+ const loadVideo = () => {
426
+ if (videoLoaded.value) return;
427
+
428
+ isLoading.value = true;
429
+ videoLoaded.value = true;
430
+
431
+ // Wait for next tick to ensure the src is set
432
+ nextTick(() => {
433
+ if (videoElement.value) {
434
+ videoElement.value.load();
435
+ // Auto-play after loading if user clicked play
436
+ videoElement.value.addEventListener(
437
+ "canplay",
438
+ () => {
439
+ videoElement.value.play();
440
+ },
441
+ { once: true }
442
+ );
443
+ }
444
+ });
445
+ };
446
+
447
+ const seek = (event) => {
448
+ if (!videoElement.value) return;
449
+
450
+ // Load video if not already loaded
451
+ if (!videoLoaded.value) {
452
+ loadVideo();
453
+ return;
454
+ }
455
+
456
+ if (!duration.value) return;
457
+
458
+ const rect = event.currentTarget.getBoundingClientRect();
459
+ const clickX = event.clientX - rect.left;
460
+ const percentage = clickX / rect.width;
461
+ const newTime = percentage * duration.value;
462
+
463
+ videoElement.value.currentTime = newTime;
464
+ currentTime.value = newTime;
465
+ };
466
+
467
+ const startSeeking = (event) => {
468
+ isSeeking.value = true;
469
+ seek(event);
470
+
471
+ const handleMouseMove = (e) => {
472
+ if (isSeeking.value) seek(e);
473
+ };
474
+
475
+ const handleMouseUp = () => {
476
+ isSeeking.value = false;
477
+ document.removeEventListener("mousemove", handleMouseMove);
478
+ document.removeEventListener("mouseup", handleMouseUp);
479
+ };
480
+
481
+ document.addEventListener("mousemove", handleMouseMove);
482
+ document.addEventListener("mouseup", handleMouseUp);
483
+ };
484
+
485
+ const onProgressHover = (event) => {
486
+ if (!duration.value) return;
487
+
488
+ const rect = event.currentTarget.getBoundingClientRect();
489
+ const hoverX = event.clientX - rect.left;
490
+ const percentage = hoverX / rect.width;
491
+ const hoverTime = percentage * duration.value;
492
+
493
+ progressPreview.value = {
494
+ visible: true,
495
+ x: hoverX,
496
+ time: hoverTime,
497
+ };
498
+ };
499
+
500
+ const hideProgressPreview = () => {
501
+ progressPreview.value.visible = false;
502
+ };
503
+
504
+ const toggleMute = () => {
505
+ if (!videoElement.value) return;
506
+
507
+ // Load video if not loaded yet (volume can be set before loading)
508
+ if (!videoLoaded.value) {
509
+ if (isMuted.value) {
510
+ isMuted.value = false;
511
+ } else {
512
+ isMuted.value = true;
513
+ }
514
+ return;
515
+ }
516
+
517
+ if (isMuted.value) {
518
+ videoElement.value.volume = volume.value;
519
+ isMuted.value = false;
520
+ } else {
521
+ videoElement.value.volume = 0;
522
+ isMuted.value = true;
523
+ }
524
+ };
525
+
526
+ const setVolume = () => {
527
+ if (!videoElement.value || !videoLoaded.value) return;
528
+
529
+ videoElement.value.volume = volume.value;
530
+ isMuted.value = volume.value === 0;
531
+ };
532
+
533
+ const toggleFullscreen = async () => {
534
+ if (!playerContainer.value) return;
535
+
536
+ try {
537
+ if (!isFullscreen.value) {
538
+ if (playerContainer.value.requestFullscreen) {
539
+ await playerContainer.value.requestFullscreen();
540
+ } else if (playerContainer.value.webkitRequestFullscreen) {
541
+ await playerContainer.value.webkitRequestFullscreen();
542
+ }
543
+ } else {
544
+ if (document.exitFullscreen) {
545
+ await document.exitFullscreen();
546
+ } else if (document.webkitExitFullscreen) {
547
+ await document.webkitExitFullscreen();
548
+ }
549
+ }
550
+ } catch (error) {
551
+ console.error("Fullscreen error:", error);
552
+ }
553
+ };
554
+
555
+ const togglePictureInPicture = async () => {
556
+ if (!videoElement.value) return;
557
+
558
+ // Load video first if not loaded
559
+ if (!videoLoaded.value) {
560
+ loadVideo();
561
+ return;
562
+ }
563
+
564
+ try {
565
+ if (document.pictureInPictureElement) {
566
+ await document.exitPictureInPicture();
567
+ } else {
568
+ await videoElement.value.requestPictureInPicture();
569
+ }
570
+ } catch (error) {
571
+ console.error("PiP error:", error);
572
+ }
573
+ };
574
+
575
+ const toggleSpeedMenu = () => {
576
+ speedMenuVisible.value = !speedMenuVisible.value;
577
+ // Close other menus
578
+ settingsMenuVisible.value = false;
579
+ };
580
+
581
+ const setPlaybackSpeed = (speed) => {
582
+ if (!videoElement.value) return;
583
+
584
+ playbackRate.value = speed;
585
+
586
+ // Only set playback rate if video is loaded
587
+ if (videoLoaded.value) {
588
+ videoElement.value.playbackRate = speed;
589
+ }
590
+
591
+ speedMenuVisible.value = false;
592
+ };
593
+
594
+ const toggleSettingsMenu = () => {
595
+ settingsMenuVisible.value = !settingsMenuVisible.value;
596
+ // Close other menus
597
+ speedMenuVisible.value = false;
598
+ };
599
+
600
+ const handleClickOutside = (event) => {
601
+ // Close menus if clicking outside
602
+ if (
603
+ !event.target.closest(".settings-container") &&
604
+ !event.target.closest(".speed-container")
605
+ ) {
606
+ settingsMenuVisible.value = false;
607
+ speedMenuVisible.value = false;
608
+ }
609
+ };
610
+
611
+ const setupIntersectionObserver = () => {
612
+ if (!playerContainer.value || !("IntersectionObserver" in window)) {
613
+ // Fallback: preload immediately if no intersection observer support
614
+ shouldPreloadFrames.value = true;
615
+ return;
616
+ }
617
+
618
+ intersectionObserver.value = new IntersectionObserver(
619
+ (entries) => {
620
+ entries.forEach((entry) => {
621
+ if (entry.isIntersecting) {
622
+ inViewport.value = true;
623
+ // Start preloading frames when video comes into view
624
+ if (!shouldPreloadFrames.value) {
625
+ shouldPreloadFrames.value = true;
626
+ // Give it a moment for the src to be set, then start preloading
627
+ nextTick(() => {
628
+ preloadVideoFrames();
629
+ });
630
+ }
631
+ } else {
632
+ inViewport.value = false;
633
+ }
634
+ });
635
+ },
636
+ {
637
+ root: null,
638
+ rootMargin: "50px", // Start loading when video is 50px away from viewport
639
+ threshold: 0.1,
640
+ }
641
+ );
642
+
643
+ intersectionObserver.value.observe(playerContainer.value);
644
+ };
645
+
646
+ const preloadVideoFrames = () => {
647
+ if (!videoElement.value || videoLoaded.value) return;
648
+
649
+ // Set video source and preload metadata to get first frame
650
+ if (videoElement.value.readyState === 0) {
651
+ videoElement.value.addEventListener(
652
+ "loadedmetadata",
653
+ () => {
654
+ // Video metadata is now loaded, first frame should be available
655
+ console.log("Video metadata preloaded, first frame ready");
656
+ },
657
+ { once: true }
658
+ );
659
+
660
+ videoElement.value.addEventListener(
661
+ "canplay",
662
+ () => {
663
+ // Video is ready to play, frames have been buffered
664
+ console.log("Video frames preloaded and ready for playback");
665
+ },
666
+ { once: true }
667
+ );
668
+ }
669
+ };
670
+
671
+ const formatTime = (seconds) => {
672
+ if (!seconds || isNaN(seconds)) return "0:00";
673
+
674
+ const minutes = Math.floor(seconds / 60);
675
+ const remainingSeconds = Math.floor(seconds % 60);
676
+
677
+ if (minutes >= 60) {
678
+ const hours = Math.floor(minutes / 60);
679
+ const remainingMinutes = minutes % 60;
680
+ return `${hours}:${remainingMinutes
681
+ .toString()
682
+ .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
683
+ }
684
+
685
+ return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
686
+ };
687
+
688
+ const handleMouseMove = () => {
689
+ showControls();
690
+ };
691
+
692
+ const showControls = () => {
693
+ controlsVisible.value = true;
694
+
695
+ if (controlsTimeout.value) {
696
+ clearTimeout(controlsTimeout.value);
697
+ }
698
+
699
+ controlsTimeout.value = setTimeout(() => {
700
+ if (isPlaying.value) {
701
+ controlsVisible.value = false;
702
+ }
703
+ }, 3000);
704
+ };
705
+
706
+ const hideControls = () => {
707
+ if (isPlaying.value) {
708
+ controlsVisible.value = false;
709
+ }
710
+ };
711
+
712
+ // Event handlers
713
+ const onVideoLoaded = () => {
714
+ isLoading.value = false;
715
+ duration.value = videoElement.value.duration;
716
+
717
+ // Apply volume and playback rate settings that may have been set before loading
718
+ if (videoElement.value) {
719
+ videoElement.value.volume = isMuted.value ? 0 : volume.value;
720
+ videoElement.value.playbackRate = playbackRate.value;
721
+ }
722
+
723
+ if (props.autoplay && videoLoaded.value) {
724
+ videoElement.value.play();
725
+ }
726
+ };
727
+
728
+ const onTimeUpdate = () => {
729
+ if (!isSeeking.value) {
730
+ currentTime.value = videoElement.value.currentTime;
731
+ }
732
+
733
+ emit("timeupdate", {
734
+ currentTime: currentTime.value,
735
+ duration: duration.value,
736
+ percentage: playedPercentage.value,
737
+ });
738
+ };
739
+
740
+ const onProgress = () => {
741
+ if (videoElement.value && videoElement.value.buffered.length > 0) {
742
+ bufferedTime.value = videoElement.value.buffered.end(
743
+ videoElement.value.buffered.length - 1
744
+ );
745
+ }
746
+ };
747
+
748
+ const onVideoEnded = () => {
749
+ isPlaying.value = false;
750
+ emit("ended");
751
+ };
752
+
753
+ const handleFullscreenChange = () => {
754
+ isFullscreen.value = !!(
755
+ document.fullscreenElement ||
756
+ document.webkitFullscreenElement ||
757
+ document.mozFullScreenElement ||
758
+ document.msFullscreenElement
759
+ );
760
+ };
761
+
762
+ const handleKeydown = (event) => {
763
+ if (!playerContainer.value || event.target.tagName === "INPUT") return;
764
+
765
+ // Create a key combination string
766
+ const modifiers = [];
767
+ if (event.ctrlKey || event.metaKey) modifiers.push("Ctrl");
768
+ if (event.shiftKey) modifiers.push("Shift");
769
+ if (event.altKey) modifiers.push("Alt");
770
+
771
+ const keyCombo =
772
+ modifiers.length > 0 ? `${modifiers.join("+")}+${event.key}` : event.key;
773
+
774
+ switch (keyCombo) {
775
+ // Play/Pause - Ctrl+Space or Ctrl+K
776
+ case "Ctrl+ ":
777
+ case "Ctrl+k":
778
+ event.preventDefault();
779
+ togglePlayPause();
780
+ break;
781
+
782
+ // Fullscreen - Ctrl+F
783
+ case "Ctrl+f":
784
+ event.preventDefault();
785
+ toggleFullscreen();
786
+ break;
787
+
788
+ // Mute/Unmute - Ctrl+M
789
+ case "Ctrl+m":
790
+ event.preventDefault();
791
+ toggleMute();
792
+ break;
793
+
794
+ // Seek backward 10s - Ctrl+Left Arrow
795
+ case "Ctrl+ArrowLeft":
796
+ event.preventDefault();
797
+ if (!videoLoaded.value) {
798
+ loadVideo();
799
+ return;
800
+ }
801
+ if (videoElement.value) {
802
+ videoElement.value.currentTime = Math.max(0, currentTime.value - 10);
803
+ }
804
+ break;
805
+
806
+ // Seek forward 10s - Ctrl+Right Arrow
807
+ case "Ctrl+ArrowRight":
808
+ event.preventDefault();
809
+ if (!videoLoaded.value) {
810
+ loadVideo();
811
+ return;
812
+ }
813
+ if (videoElement.value) {
814
+ videoElement.value.currentTime = Math.min(
815
+ duration.value,
816
+ currentTime.value + 10
817
+ );
818
+ }
819
+ break;
820
+
821
+ // Seek backward 30s - Shift+Left Arrow
822
+ case "Shift+ArrowLeft":
823
+ event.preventDefault();
824
+ if (!videoLoaded.value) {
825
+ loadVideo();
826
+ return;
827
+ }
828
+ if (videoElement.value) {
829
+ videoElement.value.currentTime = Math.max(0, currentTime.value - 30);
830
+ }
831
+ break;
832
+
833
+ // Seek forward 30s - Shift+Right Arrow
834
+ case "Shift+ArrowRight":
835
+ event.preventDefault();
836
+ if (!videoLoaded.value) {
837
+ loadVideo();
838
+ return;
839
+ }
840
+ if (videoElement.value) {
841
+ videoElement.value.currentTime = Math.min(
842
+ duration.value,
843
+ currentTime.value + 30
844
+ );
845
+ }
846
+ break;
847
+
848
+ // Volume up - Ctrl+Up Arrow
849
+ case "Ctrl+ArrowUp":
850
+ event.preventDefault();
851
+ volume.value = Math.min(1, volume.value + 0.1);
852
+ setVolume();
853
+ break;
854
+
855
+ // Volume down - Ctrl+Down Arrow
856
+ case "Ctrl+ArrowDown":
857
+ event.preventDefault();
858
+ volume.value = Math.max(0, volume.value - 0.1);
859
+ setVolume();
860
+ break;
861
+
862
+ // Fine volume up - Shift+Up Arrow
863
+ case "Shift+ArrowUp":
864
+ event.preventDefault();
865
+ volume.value = Math.min(1, volume.value + 0.05);
866
+ setVolume();
867
+ break;
868
+
869
+ // Fine volume down - Shift+Down Arrow
870
+ case "Shift+ArrowDown":
871
+ event.preventDefault();
872
+ volume.value = Math.max(0, volume.value - 0.05);
873
+ setVolume();
874
+ break;
875
+
876
+ // Speed increase - Ctrl+Plus/Equal
877
+ case "Ctrl+=":
878
+ case "Ctrl++":
879
+ event.preventDefault();
880
+ const nextSpeedIndex = speedOptions.findIndex(
881
+ (speed) => speed > playbackRate.value
882
+ );
883
+ if (nextSpeedIndex !== -1) {
884
+ setPlaybackSpeed(speedOptions[nextSpeedIndex]);
885
+ }
886
+ break;
887
+
888
+ // Speed decrease - Ctrl+Minus
889
+ case "Ctrl+-":
890
+ event.preventDefault();
891
+ const prevSpeedIndex = speedOptions
892
+ .slice()
893
+ .reverse()
894
+ .findIndex((speed) => speed < playbackRate.value);
895
+ if (prevSpeedIndex !== -1) {
896
+ setPlaybackSpeed(
897
+ speedOptions[speedOptions.length - 1 - prevSpeedIndex]
898
+ );
899
+ }
900
+ break;
901
+
902
+ // Reset speed to normal - Ctrl+0
903
+ case "Ctrl+0":
904
+ event.preventDefault();
905
+ setPlaybackSpeed(1);
906
+ break;
907
+
908
+ // Picture in Picture - Alt+P
909
+ case "Alt+p":
910
+ event.preventDefault();
911
+ if (supportsPiP.value) {
912
+ togglePictureInPicture();
913
+ }
914
+ break;
915
+
916
+ // Next video - Ctrl+N (if available)
917
+ case "Ctrl+n":
918
+ if (props.hasNext) {
919
+ event.preventDefault();
920
+ emit("next");
921
+ }
922
+ break;
923
+
924
+ // Show/Hide Settings/Help - Ctrl+H or Ctrl+?
925
+ case "Ctrl+h":
926
+ case "Ctrl+?":
927
+ event.preventDefault();
928
+ toggleSettingsMenu();
929
+ break;
930
+
931
+ // Close menus with ESC
932
+ case "Escape":
933
+ event.preventDefault();
934
+ settingsMenuVisible.value = false;
935
+ speedMenuVisible.value = false;
936
+ break;
937
+ }
938
+ };
939
+
940
+ // Lifecycle
941
+ onMounted(() => {
942
+ // Check for Picture-in-Picture support
943
+ supportsPiP.value = document.pictureInPictureEnabled && videoElement.value;
944
+
945
+ // Add event listeners
946
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
947
+ document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
948
+ document.addEventListener("keydown", handleKeydown);
949
+ document.addEventListener("click", handleClickOutside);
950
+
951
+ // Show controls initially
952
+ showControls();
953
+
954
+ // Setup intersection observer for smart loading
955
+ nextTick(() => {
956
+ setupIntersectionObserver();
957
+ });
958
+
959
+ // Load video immediately if autoplay is enabled or lazy loading is disabled
960
+ if (props.autoplay || !props.lazy) {
961
+ loadVideo();
962
+ }
963
+ });
964
+
965
+ onUnmounted(() => {
966
+ if (controlsTimeout.value) {
967
+ clearTimeout(controlsTimeout.value);
968
+ }
969
+
970
+ // Cleanup intersection observer
971
+ if (intersectionObserver.value) {
972
+ intersectionObserver.value.disconnect();
973
+ intersectionObserver.value = null;
974
+ }
975
+
976
+ document.removeEventListener("fullscreenchange", handleFullscreenChange);
977
+ document.removeEventListener(
978
+ "webkitfullscreenchange",
979
+ handleFullscreenChange
980
+ );
981
+ document.removeEventListener("keydown", handleKeydown);
982
+ document.removeEventListener("click", handleClickOutside);
983
+ });
984
+
985
+ // Watch volume changes
986
+ watch(volume, (newVolume) => {
987
+ if (videoLoaded.value) {
988
+ setVolume();
989
+ }
990
+ });
991
+
992
+ // Watch src changes to reset video state
993
+ watch(
994
+ () => props.src,
995
+ (newSrc, oldSrc) => {
996
+ if (newSrc !== oldSrc) {
997
+ // Reset video state when source changes
998
+ videoLoaded.value = false;
999
+ isLoading.value = false;
1000
+ isPlaying.value = false;
1001
+ currentTime.value = 0;
1002
+ duration.value = 0;
1003
+ bufferedTime.value = 0;
1004
+ shouldPreloadFrames.value = false;
1005
+
1006
+ // Restart preloading if in viewport
1007
+ if (inViewport.value) {
1008
+ shouldPreloadFrames.value = true;
1009
+ nextTick(() => {
1010
+ preloadVideoFrames();
1011
+ });
1012
+ }
1013
+
1014
+ // Load immediately if autoplay is enabled or lazy loading is disabled
1015
+ if (props.autoplay || !props.lazy) {
1016
+ loadVideo();
1017
+ }
1018
+ }
1019
+ }
1020
+ );
1021
+ </script>
1022
+
1023
+ <style scoped>
1024
+ .video-player-container {
1025
+ position: relative;
1026
+ width: 100%;
1027
+ background: #000;
1028
+
1029
+ overflow: hidden;
1030
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial,
1031
+ sans-serif;
1032
+ }
1033
+
1034
+ .video-player-container.fullscreen {
1035
+ border-radius: 0;
1036
+ width: 100vw;
1037
+ height: 100vh;
1038
+ position: fixed;
1039
+ top: 0;
1040
+ left: 0;
1041
+ z-index: 9999;
1042
+ }
1043
+
1044
+ .video-element {
1045
+ width: 100%;
1046
+ height: 100%;
1047
+ display: block;
1048
+ object-fit: contain;
1049
+ }
1050
+
1051
+ .loading-spinner {
1052
+ position: absolute;
1053
+ top: 50%;
1054
+ left: 50%;
1055
+ transform: translate(-50%, -50%);
1056
+ z-index: 2;
1057
+ }
1058
+
1059
+ .spinner {
1060
+ width: 40px;
1061
+ height: 40px;
1062
+ border: 4px solid rgba(255, 255, 255, 0.3);
1063
+ border-top: 4px solid #fff;
1064
+ border-radius: 50%;
1065
+ animation: spin 1s linear infinite;
1066
+ }
1067
+
1068
+ @keyframes spin {
1069
+ 0% {
1070
+ transform: rotate(0deg);
1071
+ }
1072
+ 100% {
1073
+ transform: rotate(360deg);
1074
+ }
1075
+ }
1076
+
1077
+ .play-overlay {
1078
+ position: absolute;
1079
+ top: 50%;
1080
+ left: 50%;
1081
+ transform: translate(-50%, -50%);
1082
+ z-index: 3;
1083
+ cursor: pointer;
1084
+ }
1085
+
1086
+ .play-button-large {
1087
+ background: rgba(0, 0, 0, 0.8);
1088
+ border: none;
1089
+ border-radius: 50%;
1090
+ width: 80px;
1091
+ height: 80px;
1092
+ display: flex;
1093
+ align-items: center;
1094
+ justify-content: center;
1095
+ cursor: pointer;
1096
+ transition: all 0.3s ease;
1097
+ }
1098
+
1099
+ .play-button-large:hover {
1100
+ background: rgba(0, 0, 0, 0.9);
1101
+ transform: scale(1.1);
1102
+ }
1103
+
1104
+ .play-button-large svg {
1105
+ width: 32px;
1106
+ height: 32px;
1107
+ color: white;
1108
+ margin-left: 4px;
1109
+ }
1110
+
1111
+ .controls-container {
1112
+ position: absolute;
1113
+ bottom: 0;
1114
+ left: 0;
1115
+ right: 0;
1116
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
1117
+ padding: 20px 16px 12px;
1118
+ opacity: 0;
1119
+ transition: opacity 0.3s ease;
1120
+ z-index: 4;
1121
+ }
1122
+
1123
+ .controls-container.visible,
1124
+ .controls-container.always-visible {
1125
+ opacity: 1;
1126
+ }
1127
+
1128
+ .progress-container {
1129
+ margin-bottom: 12px;
1130
+ }
1131
+
1132
+ .progress-bar {
1133
+ position: relative;
1134
+ height: 6px;
1135
+ cursor: pointer;
1136
+ border-radius: 3px;
1137
+ overflow: visible;
1138
+ }
1139
+
1140
+ .progress-bar:hover {
1141
+ height: 8px;
1142
+ }
1143
+
1144
+ .progress-background {
1145
+ position: absolute;
1146
+ top: 0;
1147
+ left: 0;
1148
+ right: 0;
1149
+ bottom: 0;
1150
+ background: rgba(255, 255, 255, 0.3);
1151
+ border-radius: 3px;
1152
+ }
1153
+
1154
+ .progress-buffered {
1155
+ position: absolute;
1156
+ top: 0;
1157
+ left: 0;
1158
+ bottom: 0;
1159
+ background: rgba(255, 255, 255, 0.5);
1160
+ border-radius: 3px;
1161
+ transition: width 0.2s ease;
1162
+ }
1163
+
1164
+ .progress-played {
1165
+ position: absolute;
1166
+ top: 0;
1167
+ left: 0;
1168
+ bottom: 0;
1169
+ background: #ff0000;
1170
+ border-radius: 3px;
1171
+ transition: width 0.1s ease;
1172
+ }
1173
+
1174
+ .progress-handle {
1175
+ position: absolute;
1176
+ top: 50%;
1177
+ width: 14px;
1178
+ height: 14px;
1179
+ background: #ff0000;
1180
+ border-radius: 50%;
1181
+ transform: translate(-50%, -50%);
1182
+ opacity: 0;
1183
+ transition: all 0.2s ease;
1184
+ }
1185
+
1186
+ .progress-bar:hover .progress-handle {
1187
+ opacity: 1;
1188
+ }
1189
+
1190
+ .progress-preview {
1191
+ position: absolute;
1192
+ bottom: 20px;
1193
+ background: rgba(0, 0, 0, 0.9);
1194
+ color: white;
1195
+ padding: 4px 8px;
1196
+ border-radius: 4px;
1197
+ font-size: 12px;
1198
+ pointer-events: none;
1199
+ transform: translateX(-50%);
1200
+ white-space: nowrap;
1201
+ }
1202
+
1203
+ .controls-row {
1204
+ display: flex;
1205
+ align-items: center;
1206
+ justify-content: space-between;
1207
+ color: white;
1208
+ }
1209
+
1210
+ .controls-left,
1211
+ .controls-right {
1212
+ display: flex;
1213
+ align-items: center;
1214
+ gap: 8px;
1215
+ }
1216
+
1217
+ .control-button {
1218
+ background: none;
1219
+ border: none;
1220
+ color: white;
1221
+ cursor: pointer;
1222
+ padding: 8px;
1223
+ border-radius: 4px;
1224
+ display: flex;
1225
+ align-items: center;
1226
+ justify-content: center;
1227
+ transition: background-color 0.2s ease;
1228
+ }
1229
+
1230
+ .control-button:hover {
1231
+ background: rgba(255, 255, 255, 0.1);
1232
+ }
1233
+
1234
+ .control-button svg {
1235
+ width: 20px;
1236
+ height: 20px;
1237
+ fill: currentColor;
1238
+ }
1239
+
1240
+ .volume-container {
1241
+ position: relative;
1242
+ display: flex;
1243
+ align-items: center;
1244
+ }
1245
+
1246
+ .volume-slider-container {
1247
+ position: absolute;
1248
+ left: 100%;
1249
+ bottom: 0;
1250
+ margin-left: 4px;
1251
+ opacity: 0;
1252
+ pointer-events: none;
1253
+ transition: opacity 0.2s ease;
1254
+ padding: 8px 4px 8px 8px; /* Add padding to create hover bridge */
1255
+ margin-left: 0px; /* Remove margin since we have padding now */
1256
+ }
1257
+
1258
+ .volume-container:hover .volume-slider-container,
1259
+ .volume-slider-container.visible {
1260
+ opacity: 1;
1261
+ pointer-events: auto;
1262
+ }
1263
+
1264
+ .volume-slider {
1265
+ width: 80px;
1266
+ height: 4px;
1267
+ background: rgba(255, 255, 255, 0.3);
1268
+ border-radius: 2px;
1269
+ outline: none;
1270
+ cursor: pointer;
1271
+ }
1272
+
1273
+ .volume-slider::-webkit-slider-thumb {
1274
+ appearance: none;
1275
+ width: 14px;
1276
+ height: 14px;
1277
+ background: #ff0000;
1278
+ border-radius: 50%;
1279
+ cursor: pointer;
1280
+ }
1281
+
1282
+ .volume-slider::-moz-range-thumb {
1283
+ width: 14px;
1284
+ height: 14px;
1285
+ background: #ff0000;
1286
+ border-radius: 50%;
1287
+ cursor: pointer;
1288
+ border: none;
1289
+ }
1290
+
1291
+ .time-display {
1292
+ font-size: 14px;
1293
+ font-weight: 500;
1294
+ white-space: nowrap;
1295
+ transition: margin-left 0.2s ease;
1296
+ }
1297
+
1298
+ .volume-container:hover + .time-display {
1299
+ margin-left: 92px; /* 80px slider width + 12px padding */
1300
+ }
1301
+
1302
+ .speed-container {
1303
+ position: relative;
1304
+ }
1305
+
1306
+ .speed-button {
1307
+ font-size: 14px;
1308
+ font-weight: 500;
1309
+ min-width: 40px;
1310
+ }
1311
+
1312
+ .speed-menu {
1313
+ position: absolute;
1314
+ bottom: 100%;
1315
+ left: 50%;
1316
+ transform: translateX(-50%);
1317
+ background: rgba(28, 28, 28, 0.95);
1318
+ border-radius: 8px;
1319
+ padding: 8px 0;
1320
+ margin-bottom: 8px;
1321
+ opacity: 0;
1322
+ pointer-events: none;
1323
+ transition: opacity 0.2s ease;
1324
+ backdrop-filter: blur(10px);
1325
+ }
1326
+
1327
+ .speed-menu.visible {
1328
+ opacity: 1;
1329
+ pointer-events: auto;
1330
+ }
1331
+
1332
+ .speed-option {
1333
+ padding: 8px 16px;
1334
+ cursor: pointer;
1335
+ font-size: 14px;
1336
+ white-space: nowrap;
1337
+ transition: background-color 0.2s ease;
1338
+ }
1339
+
1340
+ .speed-option:hover {
1341
+ background: rgba(255, 255, 255, 0.1);
1342
+ }
1343
+
1344
+ .speed-option.active {
1345
+ color: #ff0000;
1346
+ font-weight: 500;
1347
+ }
1348
+
1349
+ /* Responsive */
1350
+ @media (max-width: 768px) {
1351
+ .controls-container {
1352
+ padding: 16px 12px 8px;
1353
+ }
1354
+
1355
+ .controls-left,
1356
+ .controls-right {
1357
+ gap: 4px;
1358
+ }
1359
+
1360
+ .control-button {
1361
+ padding: 6px;
1362
+ }
1363
+
1364
+ .control-button svg {
1365
+ width: 18px;
1366
+ height: 18px;
1367
+ }
1368
+
1369
+ .time-display {
1370
+ font-size: 12px;
1371
+ }
1372
+
1373
+ .play-button-large {
1374
+ width: 60px;
1375
+ height: 60px;
1376
+ }
1377
+
1378
+ .play-button-large svg {
1379
+ width: 24px;
1380
+ height: 24px;
1381
+ }
1382
+ }
1383
+
1384
+ @media (max-width: 480px) {
1385
+ .volume-slider-container {
1386
+ display: none;
1387
+ }
1388
+
1389
+ .speed-container {
1390
+ display: none;
1391
+ }
1392
+ }
1393
+
1394
+ /* Settings Menu Styles */
1395
+ .settings-container {
1396
+ position: relative;
1397
+ display: inline-block;
1398
+ }
1399
+
1400
+ .settings-menu {
1401
+ position: absolute;
1402
+ bottom: 100%;
1403
+ right: 0;
1404
+ background: rgba(0, 0, 0, 0.9);
1405
+ border-radius: 6px;
1406
+ min-width: 300px;
1407
+ max-height: 400px;
1408
+ overflow-y: auto;
1409
+ z-index: 1000;
1410
+ opacity: 0;
1411
+ visibility: hidden;
1412
+ transform: translateY(10px);
1413
+ transition: all 0.2s ease;
1414
+ backdrop-filter: blur(10px);
1415
+ border: 1px solid rgba(255, 255, 255, 0.1);
1416
+ }
1417
+
1418
+ .settings-menu.visible {
1419
+ opacity: 1;
1420
+ visibility: visible;
1421
+ transform: translateY(0);
1422
+ }
1423
+
1424
+ .settings-section {
1425
+ padding: 16px;
1426
+ }
1427
+
1428
+ .settings-section h3 {
1429
+ margin: 0 0 12px 0;
1430
+ font-size: 14px;
1431
+ font-weight: 600;
1432
+ color: #ffffff;
1433
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
1434
+ padding-bottom: 8px;
1435
+ }
1436
+
1437
+ .shortcuts-list {
1438
+ display: flex;
1439
+ flex-direction: column;
1440
+ gap: 8px;
1441
+ }
1442
+
1443
+ .shortcut-item {
1444
+ display: flex;
1445
+ justify-content: space-between;
1446
+ align-items: center;
1447
+ padding: 6px 0;
1448
+ font-size: 12px;
1449
+ }
1450
+
1451
+ .shortcut-key {
1452
+ background: rgba(255, 255, 255, 0.1);
1453
+ color: #ffffff;
1454
+ padding: 4px 8px;
1455
+ border-radius: 4px;
1456
+ font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
1457
+ font-size: 11px;
1458
+ font-weight: 500;
1459
+ border: 1px solid rgba(255, 255, 255, 0.2);
1460
+ min-width: 120px;
1461
+ text-align: center;
1462
+ }
1463
+
1464
+ .shortcut-desc {
1465
+ color: #cccccc;
1466
+ text-align: right;
1467
+ flex: 1;
1468
+ margin-left: 12px;
1469
+ }
1470
+
1471
+ .settings-container.active .control-button {
1472
+ background: rgba(255, 255, 255, 0.2);
1473
+ }
1474
+
1475
+ @media (max-width: 768px) {
1476
+ .settings-menu {
1477
+ min-width: 280px;
1478
+ max-width: 90vw;
1479
+ }
1480
+
1481
+ .shortcut-key {
1482
+ min-width: 100px;
1483
+ font-size: 10px;
1484
+ }
1485
+
1486
+ .shortcut-desc {
1487
+ font-size: 11px;
1488
+ }
1489
+ }
1490
+
1491
+ @media (max-width: 480px) {
1492
+ .settings-container {
1493
+ display: none;
1494
+ }
1495
+ }
1496
+ </style>