@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,70 @@
1
+ import { ref, onUnmounted } from 'vue';
2
+
3
+ /**
4
+ * 控件自动隐藏 composable
5
+ * 用于全屏模式下自动隐藏控件
6
+ */
7
+ export function useAutoHideControls(options: {
8
+ /** 隐藏延时(毫秒) */
9
+ delay?: number;
10
+ /** 是否启用(通常与全屏状态绑定) */
11
+ isEnabled: () => boolean;
12
+ } = { delay: 2000, isEnabled: () => true }) {
13
+ const { delay = 2000, isEnabled } = options;
14
+
15
+ const showControls = ref(true);
16
+ let hideTimer: number | undefined;
17
+
18
+ /** 清除隐藏计时器 */
19
+ function clearHideTimer() {
20
+ if (hideTimer) {
21
+ clearTimeout(hideTimer);
22
+ hideTimer = undefined;
23
+ }
24
+ }
25
+
26
+ /** 重置隐藏计时器 */
27
+ function resetHideTimer() {
28
+ showControls.value = true;
29
+ clearHideTimer();
30
+
31
+ if (isEnabled()) {
32
+ hideTimer = window.setTimeout(() => {
33
+ showControls.value = false;
34
+ }, delay);
35
+ }
36
+ }
37
+
38
+ /** 强制显示控件 */
39
+ function showControlsNow() {
40
+ showControls.value = true;
41
+ clearHideTimer();
42
+ }
43
+
44
+ /** 强制隐藏控件 */
45
+ function hideControlsNow() {
46
+ showControls.value = false;
47
+ clearHideTimer();
48
+ }
49
+
50
+ /** 鼠标移动时重置计时器 */
51
+ function handleMouseMove() {
52
+ if (isEnabled()) {
53
+ resetHideTimer();
54
+ }
55
+ }
56
+
57
+ onUnmounted(() => {
58
+ clearHideTimer();
59
+ });
60
+
61
+ return {
62
+ showControls,
63
+ resetHideTimer,
64
+ clearHideTimer,
65
+ showControlsNow,
66
+ hideControlsNow,
67
+ handleMouseMove
68
+ };
69
+ }
70
+
@@ -0,0 +1,64 @@
1
+ import { ref, onMounted, onUnmounted } from 'vue';
2
+
3
+ /**
4
+ * 全屏控制 composable
5
+ */
6
+ export function useFullscreen(getElement: () => HTMLElement | undefined) {
7
+ const isFullscreen = ref(false);
8
+
9
+ /** 切换全屏 */
10
+ async function toggleFullscreen() {
11
+ const el = getElement();
12
+ if (!el) return;
13
+
14
+ if (!document.fullscreenElement) {
15
+ await el.requestFullscreen();
16
+ } else {
17
+ await document.exitFullscreen();
18
+ }
19
+ }
20
+
21
+ /** 进入全屏 */
22
+ async function enterFullscreen() {
23
+ const el = getElement();
24
+ if (!el || document.fullscreenElement) return;
25
+ await el.requestFullscreen();
26
+ }
27
+
28
+ /** 退出全屏 */
29
+ async function exitFullscreen() {
30
+ if (!document.fullscreenElement) return;
31
+ await document.exitFullscreen();
32
+ }
33
+
34
+ /** 全屏变化回调 */
35
+ let onFullscreenChange: ((isFullscreen: boolean) => void) | undefined;
36
+
37
+ /** 处理全屏变化事件 */
38
+ function handleFullscreenChange() {
39
+ isFullscreen.value = !!document.fullscreenElement;
40
+ onFullscreenChange?.(isFullscreen.value);
41
+ }
42
+
43
+ /** 设置全屏变化回调 */
44
+ function setOnFullscreenChange(callback: (isFullscreen: boolean) => void) {
45
+ onFullscreenChange = callback;
46
+ }
47
+
48
+ onMounted(() => {
49
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
50
+ });
51
+
52
+ onUnmounted(() => {
53
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
54
+ });
55
+
56
+ return {
57
+ isFullscreen,
58
+ toggleFullscreen,
59
+ enterFullscreen,
60
+ exitFullscreen,
61
+ setOnFullscreenChange
62
+ };
63
+ }
64
+
@@ -0,0 +1,324 @@
1
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
2
+ import { formatTime, formatTimeDisplay } from '../utils/time';
3
+
4
+ export interface UseMediaPlayerOptions {
5
+ initialVolume?: number;
6
+ initialMuted?: boolean;
7
+ initialPlaybackRate?: number;
8
+ initialLoop?: boolean;
9
+ }
10
+
11
+ /**
12
+ * 媒体播放器核心逻辑 composable
13
+ * 统一音频和视频播放器的通用逻辑
14
+ */
15
+ export function useMediaPlayer(
16
+ getMediaElement: () => HTMLMediaElement | undefined,
17
+ options?: UseMediaPlayerOptions,
18
+ ) {
19
+ const vol = Math.max(0, Math.min(1, options?.initialVolume ?? 1));
20
+ const rate = Math.max(0.25, Math.min(4, options?.initialPlaybackRate ?? 1));
21
+ const initialLoop = options?.initialLoop ?? false;
22
+
23
+ // 状态
24
+ const isLoading = ref(true);
25
+ const hasError = ref(false);
26
+ const isPlaying = ref(false);
27
+ const isMuted = ref(options?.initialMuted ?? false);
28
+ const loop = ref(initialLoop);
29
+ const currentTime = ref(0);
30
+ const duration = ref(0);
31
+ const buffered = ref(0);
32
+ const volume = ref(vol);
33
+ const playbackRate = ref(rate);
34
+
35
+ // 计算属性
36
+ const playedPercent = computed(() =>
37
+ duration.value ? (currentTime.value / duration.value) * 100 : 0
38
+ );
39
+
40
+ const bufferedPercent = computed(() =>
41
+ duration.value ? (buffered.value / duration.value) * 100 : 0
42
+ );
43
+
44
+ const timeDisplay = computed(() =>
45
+ formatTimeDisplay(currentTime.value, duration.value)
46
+ );
47
+
48
+ // 可用的播放速度选项
49
+ const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
50
+
51
+ /** 播放/暂停切换;播完后点击播放会从头重播 */
52
+ function togglePlay() {
53
+ const media = getMediaElement();
54
+ if (!media) return;
55
+
56
+ if (media.paused) {
57
+ const dur = media.duration;
58
+ if (Number.isFinite(dur) && dur > 0 && media.currentTime >= dur - 0.01) {
59
+ media.currentTime = 0;
60
+ currentTime.value = 0;
61
+ }
62
+ void media.play();
63
+ } else {
64
+ media.pause();
65
+ }
66
+ }
67
+
68
+ /** 播放 */
69
+ function play() {
70
+ const media = getMediaElement();
71
+ if (!media) return;
72
+ media.play();
73
+ }
74
+
75
+ /** 暂停 */
76
+ function pause() {
77
+ const media = getMediaElement();
78
+ if (!media) return;
79
+ media.pause();
80
+ }
81
+
82
+ /** 静音切换 */
83
+ function toggleMute() {
84
+ const media = getMediaElement();
85
+ if (!media) return;
86
+
87
+ media.muted = !media.muted;
88
+ isMuted.value = media.muted;
89
+ }
90
+
91
+ /** 设置静音状态(跨窗口同步时使用) */
92
+ function setMuted(value: boolean) {
93
+ const media = getMediaElement();
94
+ if (!media) return;
95
+ media.muted = value;
96
+ isMuted.value = value;
97
+ }
98
+
99
+ /** 设置音量 */
100
+ function setVolume(value: number) {
101
+ const media = getMediaElement();
102
+ if (!media) return;
103
+
104
+ const clampedValue = Math.max(0, Math.min(1, value));
105
+ media.volume = clampedValue;
106
+ volume.value = clampedValue;
107
+ media.muted = false;
108
+ isMuted.value = false;
109
+ }
110
+
111
+ /** 调整音量(增量) */
112
+ function adjustVolume(delta: number) {
113
+ const media = getMediaElement();
114
+ if (!media) return;
115
+
116
+ setVolume(media.volume + delta);
117
+ }
118
+
119
+ /** 跳转到指定时间 */
120
+ function seekTo(time: number) {
121
+ const media = getMediaElement();
122
+ if (!media) return;
123
+ const clamped = Math.max(0, Math.min(duration.value, time));
124
+ media.currentTime = clamped;
125
+ currentTime.value = clamped;
126
+ }
127
+
128
+ /** 跳转到指定百分比位置 */
129
+ function seekToPercent(percent: number) {
130
+ seekTo(percent * duration.value);
131
+ }
132
+
133
+ /** 快进/快退 */
134
+ function seek(seconds: number) {
135
+ const media = getMediaElement();
136
+ if (!media) return;
137
+ media.currentTime += seconds;
138
+ currentTime.value = media.currentTime;
139
+ }
140
+
141
+ /** 设置播放速度 */
142
+ function setSpeed(speed: number) {
143
+ const media = getMediaElement();
144
+ if (!media) return;
145
+
146
+ media.playbackRate = speed;
147
+ playbackRate.value = speed;
148
+ }
149
+
150
+ /** 设置单曲循环 */
151
+ function setLoop(value: boolean) {
152
+ const media = getMediaElement();
153
+ loop.value = value;
154
+ if (media) media.loop = value;
155
+ }
156
+
157
+ /** 切换单曲循环 */
158
+ function toggleLoop() {
159
+ setLoop(!loop.value);
160
+ }
161
+
162
+ // 媒体事件处理
163
+ function handlePlay() {
164
+ isPlaying.value = true;
165
+ }
166
+
167
+ function handlePause() {
168
+ isPlaying.value = false;
169
+ }
170
+
171
+ /** 是否暂停时间更新(拖动进度条时使用) */
172
+ let pauseTimeUpdate = false;
173
+
174
+ function setPauseTimeUpdate(value: boolean) {
175
+ pauseTimeUpdate = value;
176
+ }
177
+
178
+ function handleTimeUpdate() {
179
+ if (pauseTimeUpdate) return;
180
+
181
+ const media = getMediaElement();
182
+ if (!media) return;
183
+
184
+ currentTime.value = media.currentTime;
185
+
186
+ // 更新缓冲进度
187
+ if (media.buffered.length > 0) {
188
+ buffered.value = media.buffered.end(media.buffered.length - 1);
189
+ }
190
+ }
191
+
192
+ function handleLoadedMetadata() {
193
+ const media = getMediaElement();
194
+ if (!media) return;
195
+
196
+ duration.value = media.duration;
197
+ isLoading.value = false;
198
+ }
199
+
200
+ function handleCanPlay() {
201
+ isLoading.value = false;
202
+ }
203
+
204
+ function handleWaiting() {
205
+ isLoading.value = true;
206
+ }
207
+
208
+ function handleError() {
209
+ isLoading.value = false;
210
+ hasError.value = true;
211
+ }
212
+
213
+ /** 键盘快捷键处理(输入框内不拦截) */
214
+ function handleKeyDown(e: KeyboardEvent) {
215
+ const target = e.target as HTMLElement;
216
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return;
217
+
218
+ switch (e.code) {
219
+ case 'Space':
220
+ e.preventDefault();
221
+ togglePlay();
222
+ break;
223
+ case 'ArrowLeft':
224
+ e.preventDefault();
225
+ seek(-10);
226
+ break;
227
+ case 'ArrowRight':
228
+ e.preventDefault();
229
+ seek(10);
230
+ break;
231
+ case 'ArrowUp':
232
+ e.preventDefault();
233
+ adjustVolume(0.1);
234
+ break;
235
+ case 'ArrowDown':
236
+ e.preventDefault();
237
+ adjustVolume(-0.1);
238
+ break;
239
+ case 'KeyM':
240
+ toggleMute();
241
+ break;
242
+ case 'KeyL':
243
+ toggleLoop();
244
+ break;
245
+ }
246
+ }
247
+
248
+ /** 自动播放(失败时抛出,不吞错) */
249
+ function autoPlay() {
250
+ const media = getMediaElement();
251
+ if (media) void media.play();
252
+ }
253
+
254
+ watch(
255
+ () => getMediaElement(),
256
+ (media) => {
257
+ if (media) {
258
+ media.volume = vol;
259
+ media.muted = options?.initialMuted ?? false;
260
+ media.playbackRate = rate;
261
+ media.loop = loop.value;
262
+ }
263
+ },
264
+ { immediate: true },
265
+ );
266
+
267
+ onMounted(() => {
268
+ document.addEventListener('keydown', handleKeyDown);
269
+ });
270
+
271
+ onUnmounted(() => {
272
+ document.removeEventListener('keydown', handleKeyDown);
273
+ });
274
+
275
+ return {
276
+ // 状态
277
+ isLoading,
278
+ hasError,
279
+ isPlaying,
280
+ isMuted,
281
+ currentTime,
282
+ duration,
283
+ buffered,
284
+ volume,
285
+ playbackRate,
286
+ loop,
287
+
288
+ // 计算属性
289
+ playedPercent,
290
+ bufferedPercent,
291
+ timeDisplay,
292
+ speedOptions,
293
+
294
+ // 方法
295
+ togglePlay,
296
+ play,
297
+ pause,
298
+ toggleMute,
299
+ setMuted,
300
+ setVolume,
301
+ adjustVolume,
302
+ seekTo,
303
+ seekToPercent,
304
+ seek,
305
+ setSpeed,
306
+ setLoop,
307
+ toggleLoop,
308
+ autoPlay,
309
+ setPauseTimeUpdate,
310
+
311
+ // 事件处理器
312
+ handlePlay,
313
+ handlePause,
314
+ handleTimeUpdate,
315
+ handleLoadedMetadata,
316
+ handleCanPlay,
317
+ handleWaiting,
318
+ handleError,
319
+
320
+ // 工具函数
321
+ formatTime
322
+ };
323
+ }
324
+
@@ -0,0 +1,32 @@
1
+ import { onMounted, onUnmounted, ref } from 'vue';
2
+
3
+ export function usePopupMenu() {
4
+ const showMenu = ref(false);
5
+
6
+ function openMenu() {
7
+ showMenu.value = true;
8
+ }
9
+
10
+ function closeMenu() {
11
+ showMenu.value = false;
12
+ }
13
+
14
+ function toggleMenu() {
15
+ showMenu.value = !showMenu.value;
16
+ }
17
+
18
+ onMounted(() => {
19
+ document.addEventListener('click', closeMenu);
20
+ });
21
+
22
+ onUnmounted(() => {
23
+ document.removeEventListener('click', closeMenu);
24
+ });
25
+
26
+ return {
27
+ showMenu,
28
+ openMenu,
29
+ closeMenu,
30
+ toggleMenu,
31
+ };
32
+ }
@@ -0,0 +1,103 @@
1
+ import { ref, onUnmounted } from 'vue';
2
+
3
+ /** 滑块方向 */
4
+ export type SliderOrientation = 'horizontal' | 'vertical';
5
+
6
+ export function useSliderDrag(options: {
7
+ /** 获取容器元素 */
8
+ getElement: () => HTMLElement | undefined;
9
+ /** 值变化回调 */
10
+ onChange: (percent: number) => void;
11
+ /** thumb 半径,用于计算边缘偏移 */
12
+ thumbRadius?: number;
13
+ /** 方向:horizontal 从左到右 0→1,vertical 从下到上 0→1 */
14
+ orientation?: SliderOrientation;
15
+ }) {
16
+ const { getElement, onChange, thumbRadius = 0, orientation = 'horizontal' } = options;
17
+
18
+ const isDragging = ref(false);
19
+
20
+ /** 计算百分比:horizontal 左=0 右=1,vertical 下=0 上=1 */
21
+ function calcPercent(clientX: number, clientY: number): number {
22
+ const el = getElement();
23
+ if (!el) return 0;
24
+
25
+ const rect = el.getBoundingClientRect();
26
+ if (orientation === 'vertical') {
27
+ const trackHeight = rect.height - thumbRadius * 2;
28
+ const offsetY = rect.bottom - thumbRadius - clientY;
29
+ return Math.max(0, Math.min(1, offsetY / trackHeight));
30
+ }
31
+ const trackWidth = rect.width - thumbRadius * 2;
32
+ const offsetX = clientX - rect.left - thumbRadius;
33
+ return Math.max(0, Math.min(1, offsetX / trackWidth));
34
+ }
35
+
36
+ function updatePosition(clientX: number, clientY: number) {
37
+ const percent = calcPercent(clientX, clientY);
38
+ onChange(percent);
39
+ }
40
+
41
+ function startDrag(clientX: number, clientY: number) {
42
+ isDragging.value = true;
43
+ updatePosition(clientX, clientY);
44
+ document.addEventListener('mousemove', handleMouseMove);
45
+ document.addEventListener('mouseup', handleMouseUp);
46
+ document.addEventListener('touchmove', handleTouchMove, { passive: false });
47
+ document.addEventListener('touchend', handleTouchEnd);
48
+ }
49
+
50
+ function endDrag() {
51
+ isDragging.value = false;
52
+ document.removeEventListener('mousemove', handleMouseMove);
53
+ document.removeEventListener('mouseup', handleMouseUp);
54
+ document.removeEventListener('touchmove', handleTouchMove);
55
+ document.removeEventListener('touchend', handleTouchEnd);
56
+ }
57
+
58
+ function handleMouseDown(e: MouseEvent) {
59
+ e.preventDefault();
60
+ startDrag(e.clientX, e.clientY);
61
+ }
62
+
63
+ function handleTouchStart(e: TouchEvent) {
64
+ if (e.touches.length === 0) return;
65
+ e.preventDefault();
66
+ const t = e.touches[0];
67
+ startDrag(t.clientX, t.clientY);
68
+ }
69
+
70
+ function handleMouseMove(e: MouseEvent) {
71
+ if (!isDragging.value) return;
72
+ updatePosition(e.clientX, e.clientY);
73
+ }
74
+
75
+ function handleTouchMove(e: TouchEvent) {
76
+ if (!isDragging.value || e.touches.length === 0) return;
77
+ e.preventDefault();
78
+ const t = e.touches[0];
79
+ updatePosition(t.clientX, t.clientY);
80
+ }
81
+
82
+ function handleMouseUp() {
83
+ endDrag();
84
+ }
85
+
86
+ function handleTouchEnd() {
87
+ endDrag();
88
+ }
89
+
90
+ function cleanup() {
91
+ endDrag();
92
+ }
93
+
94
+ onUnmounted(cleanup);
95
+
96
+ return {
97
+ isDragging,
98
+ handleMouseDown,
99
+ handleTouchStart,
100
+ cleanup,
101
+ };
102
+ }
103
+
@@ -0,0 +1,78 @@
1
+ import { computed, ref, watch, type Ref } from 'vue';
2
+ import {
3
+ getCurrentCue,
4
+ inferLanguageFromFilename,
5
+ parseSubtitleByExtension,
6
+ type SubtitleCue,
7
+ type SubtitleTrack,
8
+ } from '../utils/subtitle';
9
+
10
+ export function useSubtitleTracks(currentTime: Ref<number>) {
11
+ const subtitleTracks = ref<SubtitleTrack[]>([]);
12
+ const currentTrackIndex = ref(-1);
13
+ const currentCue = ref<SubtitleCue | null>(null);
14
+
15
+ const currentTrack = computed(() =>
16
+ currentTrackIndex.value >= 0 ? subtitleTracks.value[currentTrackIndex.value] ?? null : null,
17
+ );
18
+
19
+ function updateCurrentCue() {
20
+ if (!currentTrack.value) {
21
+ currentCue.value = null;
22
+ return;
23
+ }
24
+
25
+ currentCue.value = getCurrentCue(currentTrack.value.cues, currentTime.value);
26
+ }
27
+
28
+ async function loadSubtitleFile() {
29
+ const input = document.createElement('input');
30
+ input.type = 'file';
31
+ input.accept = '.srt,.vtt,.ass,.ssa';
32
+ input.multiple = true;
33
+
34
+ input.onchange = async () => {
35
+ if (!input.files?.length) return;
36
+
37
+ for (const file of Array.from(input.files)) {
38
+ try {
39
+ const content = await file.text();
40
+ const extension = file.name.split('.').pop() || '';
41
+ const cues = parseSubtitleByExtension(content, extension);
42
+
43
+ if (cues.length === 0) continue;
44
+
45
+ const { label, language } = inferLanguageFromFilename(file.name);
46
+ subtitleTracks.value.push({
47
+ label: label || file.name,
48
+ language,
49
+ cues,
50
+ });
51
+
52
+ if (currentTrackIndex.value === -1) {
53
+ currentTrackIndex.value = 0;
54
+ }
55
+ } catch (error) {
56
+ console.error('加载字幕失败:', file.name, error);
57
+ }
58
+ }
59
+ };
60
+
61
+ input.click();
62
+ }
63
+
64
+ function setCurrentTrack(index: number) {
65
+ currentTrackIndex.value = index;
66
+ }
67
+
68
+ watch(() => currentTime.value, updateCurrentCue);
69
+ watch(currentTrackIndex, updateCurrentCue);
70
+
71
+ return {
72
+ subtitleTracks,
73
+ currentTrackIndex,
74
+ currentCue,
75
+ loadSubtitleFile,
76
+ setCurrentTrack,
77
+ };
78
+ }