@huyooo/file-explorer-preview 0.4.29 → 0.4.30

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,436 @@
1
+ <template>
2
+ <div
3
+ ref="playerRef"
4
+ class="player"
5
+ :class="{
6
+ fullscreen: isFullscreen,
7
+ 'hide-cursor': !showControls
8
+ }"
9
+ @mousemove="handleMouseMove"
10
+ @mouseleave="handleMouseLeave"
11
+ >
12
+ <!-- 视频元素 -->
13
+ <video
14
+ ref="videoRef"
15
+ :src="url"
16
+ preload="metadata"
17
+ @play="media.handlePlay"
18
+ @pause="media.handlePause"
19
+ @timeupdate="media.handleTimeUpdate"
20
+ @loadedmetadata="handleLoadedMetadata"
21
+ @canplay="media.handleCanPlay"
22
+ @waiting="media.handleWaiting"
23
+ @error="media.handleError"
24
+ @click="handleVideoClick"
25
+ @dblclick="handleVideoDoubleClick"
26
+ />
27
+
28
+ <!-- 加载指示器 -->
29
+ <div v-if="media.isLoading.value && !media.hasError.value" class="loader" />
30
+
31
+ <!-- 播放大按钮 -->
32
+ <Transition name="fade">
33
+ <div v-if="!media.isPlaying.value && !media.hasError.value" class="big-play">
34
+ <svg viewBox="0 0 24 24" fill="currentColor">
35
+ <path d="M8 5v14l11-7z"/>
36
+ </svg>
37
+ </div>
38
+ </Transition>
39
+
40
+ <!-- 错误状态 -->
41
+ <div v-if="media.hasError.value" class="error-overlay">
42
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
43
+ <circle cx="12" cy="12" r="10"/>
44
+ <line x1="15" y1="9" x2="9" y2="15"/>
45
+ <line x1="9" y1="9" x2="15" y2="15"/>
46
+ </svg>
47
+ <p>无法播放此视频</p>
48
+ </div>
49
+
50
+ <!-- 字幕显示 -->
51
+ <SubtitleOverlay
52
+ :cue="currentCue"
53
+ :visible="currentTrackIndex >= 0"
54
+ />
55
+
56
+ <!-- 控制栏 -->
57
+ <PlayerControls
58
+ :show-controls="showControls"
59
+ :has-error="media.hasError.value"
60
+ :is-playing="media.isPlaying.value"
61
+ :is-muted="media.isMuted.value"
62
+ :current-time="media.currentTime.value"
63
+ :duration="media.duration.value"
64
+ :played-percent="media.playedPercent.value"
65
+ :buffered-percent="media.bufferedPercent.value"
66
+ :volume="media.volume.value"
67
+ :playback-rate="media.playbackRate.value"
68
+ :is-fullscreen="isFullscreen"
69
+ :show-subtitle="true"
70
+ :show-pi-p="true"
71
+ :show-fullscreen="true"
72
+ :show-restore-window-ratio="true"
73
+ :subtitle-tracks="subtitleTracks"
74
+ :current-track-index="currentTrackIndex"
75
+ :loop="media.loop.value"
76
+ @toggle-play="media.togglePlay"
77
+ @seek="media.seek"
78
+ @progress-change="handleProgressChange"
79
+ @volume-change="handleVolumeChange"
80
+ @toggle-mute="handleMuteToggle"
81
+ @speed-change="handleSpeedChange"
82
+ @toggle-pi-p="togglePiP"
83
+ @toggle-fullscreen="toggleFullscreen"
84
+ @toggle-loop="handleLoopToggle"
85
+ @track-change="setCurrentTrack"
86
+ @load-subtitle="loadSubtitleFile"
87
+ @restore-window-ratio="requestRestoreWindowRatio"
88
+ @controls-interaction-start="handleControlsInteractionStart"
89
+ @controls-interaction-end="handleControlsInteractionEnd"
90
+ />
91
+ </div>
92
+ </template>
93
+ <script setup lang="ts">
94
+ import { ref, watch, onMounted, onUnmounted } from 'vue';
95
+ import { useMediaPlayer } from '../composables/useMediaPlayer';
96
+ import { useFullscreen } from '../composables/useFullscreen';
97
+ import { useSubtitleTracks } from '../composables/useSubtitleTracks';
98
+ import { useVideoInteraction } from '../composables/useVideoInteraction';
99
+ import SubtitleOverlay from './ui/SubtitleOverlay.vue';
100
+ import PlayerControls from './ui/PlayerControls.vue';
101
+
102
+ const props = withDefaults(
103
+ defineProps<{
104
+ url: string;
105
+ name: string;
106
+ initialVolume?: number;
107
+ initialMuted?: boolean;
108
+ initialPlaybackRate?: number;
109
+ initialLoop?: boolean;
110
+ }>(),
111
+ { initialVolume: 1, initialMuted: false, initialPlaybackRate: 1, initialLoop: false },
112
+ );
113
+
114
+ const emit = defineEmits<{
115
+ loaded: [];
116
+ metadata: [value: { width: number; height: number; duration: number }];
117
+ 'update:volume': [value: number];
118
+ 'update:muted': [value: boolean];
119
+ 'update:playbackRate': [value: number];
120
+ 'update:loop': [value: boolean];
121
+ restoreWindowRatio: [value: { width: number; height: number; duration: number }];
122
+ }>();
123
+
124
+ const playerRef = ref<HTMLDivElement>();
125
+ const videoRef = ref<HTMLVideoElement>();
126
+ const naturalVideoWidth = ref(0);
127
+ const naturalVideoHeight = ref(0);
128
+ const naturalVideoDuration = ref(0);
129
+
130
+ // 使用媒体播放器 composable
131
+ const media = useMediaPlayer(() => videoRef.value, {
132
+ initialVolume: props.initialVolume,
133
+ initialMuted: props.initialMuted,
134
+ initialPlaybackRate: props.initialPlaybackRate,
135
+ initialLoop: props.initialLoop,
136
+ });
137
+
138
+ // 跨窗口同步:props 变化时同步到播放器
139
+ watch(
140
+ () => [props.initialVolume, props.initialMuted, props.initialPlaybackRate, props.initialLoop] as const,
141
+ ([vol, mut, rate, loopVal]) => {
142
+ if (vol !== undefined) media.setVolume(vol);
143
+ if (mut !== undefined) media.setMuted(mut);
144
+ if (rate !== undefined) media.setSpeed(rate);
145
+ if (loopVal !== undefined) media.setLoop(loopVal);
146
+ },
147
+ );
148
+
149
+ // 使用全屏 composable
150
+ const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen(() => playerRef.value);
151
+
152
+ const {
153
+ subtitleTracks,
154
+ currentTrackIndex,
155
+ currentCue,
156
+ loadSubtitleFile,
157
+ setCurrentTrack,
158
+ } = useSubtitleTracks(media.currentTime);
159
+
160
+ const {
161
+ showControls,
162
+ revealControls,
163
+ handleMouseMove,
164
+ handleMouseLeave,
165
+ handleControlsInteractionStart,
166
+ handleControlsInteractionEnd,
167
+ handleVideoClick,
168
+ handleVideoDoubleClick,
169
+ } = useVideoInteraction({
170
+ isPlaying: media.isPlaying,
171
+ togglePlay: media.togglePlay,
172
+ toggleFullscreen,
173
+ });
174
+
175
+ // Electron API 类型在 types/electron.d.ts 中定义
176
+
177
+ /** 媒体就绪时通知父组件(骨架屏关闭) */
178
+ const hasEmittedLoaded = ref(false);
179
+ watch(
180
+ () => [media.isLoading.value, media.hasError.value] as const,
181
+ ([loading, error]) => {
182
+ if ((!loading || error) && !hasEmittedLoaded.value) {
183
+ hasEmittedLoaded.value = true;
184
+ emit('loaded');
185
+ }
186
+ },
187
+ { immediate: true },
188
+ );
189
+
190
+ function handleLoadedMetadata() {
191
+ media.handleLoadedMetadata()
192
+ if (!videoRef.value) return
193
+ naturalVideoWidth.value = videoRef.value.videoWidth;
194
+ naturalVideoHeight.value = videoRef.value.videoHeight;
195
+ naturalVideoDuration.value = videoRef.value.duration;
196
+ emit('metadata', {
197
+ width: videoRef.value.videoWidth,
198
+ height: videoRef.value.videoHeight,
199
+ duration: videoRef.value.duration,
200
+ })
201
+ }
202
+
203
+ /** 画中画 */
204
+ async function togglePiP() {
205
+ if (!videoRef.value) return;
206
+ try {
207
+ if (document.pictureInPictureElement) {
208
+ await document.exitPictureInPicture();
209
+ } else {
210
+ await videoRef.value.requestPictureInPicture();
211
+ }
212
+ } catch (err) {
213
+ console.warn('画中画操作失败:', err);
214
+ }
215
+ }
216
+
217
+ /** 处理画中画进入 */
218
+ function handlePiPEnter() {
219
+ // 进入画中画时隐藏主窗口
220
+ if (window.electronAPI?.hideWindow) {
221
+ window.electronAPI.hideWindow();
222
+ }
223
+ }
224
+
225
+ /** 处理画中画退出 */
226
+ function handlePiPLeave() {
227
+ // 退出画中画时显示主窗口
228
+ if (window.electronAPI?.showWindow) {
229
+ window.electronAPI.showWindow();
230
+ }
231
+ }
232
+
233
+ /** 键盘事件扩展(F/P 需阻止默认避免页面行为) */
234
+ function handleKeyDown(e: KeyboardEvent) {
235
+ const target = e.target as HTMLElement;
236
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return;
237
+
238
+ switch (e.code) {
239
+ case 'Space':
240
+ case 'ArrowLeft':
241
+ case 'ArrowRight':
242
+ case 'ArrowUp':
243
+ case 'ArrowDown':
244
+ case 'KeyM':
245
+ case 'KeyL':
246
+ revealControls();
247
+ break;
248
+ case 'KeyF':
249
+ e.preventDefault();
250
+ revealControls();
251
+ toggleFullscreen();
252
+ break;
253
+ case 'KeyP':
254
+ e.preventDefault();
255
+ revealControls();
256
+ togglePiP();
257
+ break;
258
+ case 'Escape':
259
+ revealControls();
260
+ if (isFullscreen.value) {
261
+ e.preventDefault();
262
+ exitFullscreen();
263
+ }
264
+ break;
265
+ }
266
+ }
267
+
268
+ /** 进度条变化 */
269
+ function handleProgressChange(percent: number) {
270
+ media.setPauseTimeUpdate(true);
271
+ media.seekToPercent(percent);
272
+ setTimeout(() => media.setPauseTimeUpdate(false), 100);
273
+ }
274
+
275
+ /** 音量变化(拖拽时 setVolume 会取消静音) */
276
+ function handleVolumeChange(volume: number) {
277
+ media.setVolume(volume);
278
+ emit('update:volume', media.volume.value);
279
+ emit('update:muted', media.isMuted.value);
280
+ }
281
+
282
+ /** 静音切换 */
283
+ function handleMuteToggle() {
284
+ media.toggleMute();
285
+ emit('update:muted', media.isMuted.value);
286
+ }
287
+
288
+ /** 速度变化 */
289
+ function handleSpeedChange(speed: number) {
290
+ media.setSpeed(speed);
291
+ emit('update:playbackRate', media.playbackRate.value);
292
+ }
293
+
294
+ /** 循环切换 */
295
+ function handleLoopToggle() {
296
+ media.toggleLoop();
297
+ emit('update:loop', media.loop.value);
298
+ }
299
+
300
+ function requestRestoreWindowRatio() {
301
+ if (!naturalVideoWidth.value || !naturalVideoHeight.value) return;
302
+ revealControls();
303
+ emit('restoreWindowRatio', {
304
+ width: naturalVideoWidth.value,
305
+ height: naturalVideoHeight.value,
306
+ duration: naturalVideoDuration.value,
307
+ });
308
+ }
309
+
310
+ /** 宿主在窗口可见时派发,此时播放可绕过 Chromium 后台节流;宿主必须派发,否则不自动播放 */
311
+ const PREVIEW_READY_EVENT = 'preview-ready-to-show';
312
+
313
+ onMounted(() => {
314
+ document.addEventListener('keydown', handleKeyDown);
315
+ videoRef.value?.addEventListener('enterpictureinpicture', handlePiPEnter);
316
+ videoRef.value?.addEventListener('leavepictureinpicture', handlePiPLeave);
317
+ const playOnce = () => media.autoPlay();
318
+ window.addEventListener(PREVIEW_READY_EVENT, playOnce);
319
+ onUnmounted(() => window.removeEventListener(PREVIEW_READY_EVENT, playOnce));
320
+ });
321
+
322
+ onUnmounted(() => {
323
+ document.removeEventListener('keydown', handleKeyDown);
324
+ videoRef.value?.removeEventListener('enterpictureinpicture', handlePiPEnter);
325
+ videoRef.value?.removeEventListener('leavepictureinpicture', handlePiPLeave);
326
+ });
327
+ </script>
328
+
329
+
330
+
331
+ <style scoped>
332
+ .player {
333
+ width: 100%;
334
+ min-width: 0;
335
+ height: 100%;
336
+ box-sizing: border-box;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ background: #000;
341
+ position: relative;
342
+ overflow: hidden;
343
+ cursor: pointer;
344
+ }
345
+
346
+ /* 隐藏光标 */
347
+ .player.hide-cursor {
348
+ cursor: none;
349
+ }
350
+
351
+ video {
352
+ width: 100%;
353
+ height: 100%;
354
+ min-width: 0;
355
+ min-height: 0;
356
+ object-fit: contain;
357
+ display: block;
358
+ }
359
+
360
+ .player.fullscreen video {
361
+ width: 100%;
362
+ height: 100%;
363
+ max-width: none;
364
+ max-height: none;
365
+ }
366
+
367
+ /* 加载指示器 */
368
+ .loader {
369
+ position: absolute;
370
+ width: 48px;
371
+ height: 48px;
372
+ border: 3px solid color-mix(in srgb, var(--huyooo-text) 15%, transparent);
373
+ border-top-color: var(--huyooo-text);
374
+ border-radius: 50%;
375
+ animation: spin 1s linear infinite;
376
+ }
377
+
378
+ @keyframes spin {
379
+ to { transform: rotate(360deg); }
380
+ }
381
+
382
+ /* 播放大按钮 */
383
+ .big-play {
384
+ position: absolute;
385
+ width: 72px;
386
+ height: 72px;
387
+ background: var(--huyooo-overlay);
388
+ backdrop-filter: blur(8px);
389
+ border-radius: 50%;
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ pointer-events: none;
394
+ }
395
+
396
+ .big-play svg {
397
+ width: 32px;
398
+ height: 32px;
399
+ fill: var(--huyooo-on-primary, #fff);
400
+ margin-left: 3px;
401
+ }
402
+
403
+ /* 淡入淡出动画 */
404
+ .fade-enter-active,
405
+ .fade-leave-active {
406
+ transition: opacity 0.2s ease;
407
+ }
408
+ .fade-enter-from,
409
+ .fade-leave-to {
410
+ opacity: 0;
411
+ }
412
+
413
+ /* 错误状态 */
414
+ .error-overlay {
415
+ position: absolute;
416
+ inset: 0;
417
+ display: flex;
418
+ flex-direction: column;
419
+ align-items: center;
420
+ justify-content: center;
421
+ gap: 12px;
422
+ background: color-mix(in srgb, var(--huyooo-overlay) 90%, transparent);
423
+ }
424
+
425
+ .error-overlay svg {
426
+ width: 48px;
427
+ height: 48px;
428
+ color: var(--huyooo-danger);
429
+ }
430
+
431
+ .error-overlay p {
432
+ color: var(--huyooo-text);
433
+ font-size: 14px;
434
+ }
435
+
436
+ </style>
@@ -0,0 +1,3 @@
1
+ export { default as ImageViewer } from './ImageViewer.vue';
2
+ export { default as VideoPlayer } from './VideoPlayer.vue';
3
+ export { default as AudioPlayer } from './AudioPlayer.vue';