@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.
- package/CHANGELOG.md +12 -0
- package/components/Calendar.vue +367 -0
- package/components/Layout/NavigationDrawer.vue +3 -1
- package/components/VideoPlayer.vue +1496 -0
- package/middleware/01.auth.ts +2 -2
- package/nuxt.config.ts +6 -0
- package/package.json +1 -1
|
@@ -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>
|