@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,112 @@
1
+ import { onUnmounted, ref, watch, type Ref } from 'vue';
2
+
3
+ interface UseVideoInteractionOptions {
4
+ isPlaying: Ref<boolean>;
5
+ togglePlay: () => void;
6
+ toggleFullscreen: () => void;
7
+ }
8
+
9
+ const HIDE_DELAY = 2500;
10
+
11
+ export function useVideoInteraction(options: UseVideoInteractionOptions) {
12
+ const showControls = ref(true);
13
+
14
+ let hideTimer: number | undefined;
15
+ let clickTimer: number | undefined;
16
+ let clickCount = 0;
17
+ let controlsActive = false;
18
+
19
+ function clearHideTimer() {
20
+ if (hideTimer === undefined) return;
21
+ clearTimeout(hideTimer);
22
+ hideTimer = undefined;
23
+ }
24
+
25
+ function startHideTimer() {
26
+ clearHideTimer();
27
+ if (!options.isPlaying.value || controlsActive) return;
28
+
29
+ hideTimer = window.setTimeout(() => {
30
+ showControls.value = false;
31
+ }, HIDE_DELAY);
32
+ }
33
+
34
+ function revealControls() {
35
+ showControls.value = true;
36
+ startHideTimer();
37
+ }
38
+
39
+ function handleMouseMove() {
40
+ revealControls();
41
+ }
42
+
43
+ function handleMouseLeave() {
44
+ if (!options.isPlaying.value || controlsActive) return;
45
+ clearHideTimer();
46
+ showControls.value = false;
47
+ }
48
+
49
+ function handleControlsInteractionStart() {
50
+ controlsActive = true;
51
+ clearHideTimer();
52
+ showControls.value = true;
53
+ }
54
+
55
+ function handleControlsInteractionEnd() {
56
+ controlsActive = false;
57
+ startHideTimer();
58
+ }
59
+
60
+ function handleVideoClick() {
61
+ clickCount += 1;
62
+ if (clickCount !== 1) return;
63
+
64
+ clickTimer = window.setTimeout(() => {
65
+ if (clickCount === 1) {
66
+ options.togglePlay();
67
+ }
68
+ clickCount = 0;
69
+ clickTimer = undefined;
70
+ }, 200);
71
+ }
72
+
73
+ function handleVideoDoubleClick() {
74
+ if (clickTimer !== undefined) {
75
+ clearTimeout(clickTimer);
76
+ clickTimer = undefined;
77
+ }
78
+ clickCount = 0;
79
+ options.toggleFullscreen();
80
+ }
81
+
82
+ watch(
83
+ () => options.isPlaying.value,
84
+ (playing) => {
85
+ if (playing) {
86
+ startHideTimer();
87
+ return;
88
+ }
89
+
90
+ clearHideTimer();
91
+ showControls.value = true;
92
+ },
93
+ );
94
+
95
+ onUnmounted(() => {
96
+ clearHideTimer();
97
+ if (clickTimer !== undefined) {
98
+ clearTimeout(clickTimer);
99
+ }
100
+ });
101
+
102
+ return {
103
+ showControls,
104
+ revealControls,
105
+ handleMouseMove,
106
+ handleMouseLeave,
107
+ handleControlsInteractionStart,
108
+ handleControlsInteractionEnd,
109
+ handleVideoClick,
110
+ handleVideoDoubleClick,
111
+ };
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as MediaPreviewPage } from './MediaPreviewPage.vue';
2
+ export type { PreviewMediaPreferences } from './types/previewMediaPreferences';
3
+ export { DEFAULT_PREVIEW_MEDIA_PREFERENCES } from './types/previewMediaPreferences';
@@ -0,0 +1,16 @@
1
+ /** Electron API 类型定义 */
2
+ export interface ElectronAPI {
3
+ minimizeWindow: () => void;
4
+ toggleMaximizeWindow: () => void;
5
+ closeWindow: () => void;
6
+ hideWindow?: () => void;
7
+ showWindow?: () => void;
8
+ onMaximizeChange: (callback: (isMaximized: boolean) => void) => () => void;
9
+ }
10
+
11
+ declare global {
12
+ interface Window {
13
+ electronAPI?: ElectronAPI;
14
+ }
15
+ }
16
+
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 媒体预览偏好
3
+ *
4
+ * 与宿主应用(如 SuperX)的 preferences 对齐,
5
+ * 通过 props 传入,未传入时使用默认值。
6
+ */
7
+ export interface PreviewMediaPreferences {
8
+ imageFitMode: 'fit' | 'actual';
9
+ volume: number;
10
+ muted: boolean;
11
+ playbackRate: number;
12
+ loop?: boolean;
13
+ }
14
+
15
+ export const DEFAULT_PREVIEW_MEDIA_PREFERENCES: PreviewMediaPreferences = {
16
+ imageFitMode: 'fit',
17
+ volume: 1,
18
+ muted: false,
19
+ playbackRate: 1,
20
+ loop: false,
21
+ };
@@ -0,0 +1,328 @@
1
+ /**
2
+ * 字幕解析工具
3
+ * 支持 SRT、VTT、ASS/SSA 格式
4
+ */
5
+
6
+ /** 字幕条目 */
7
+ export interface SubtitleCue {
8
+ /** 开始时间(秒) */
9
+ start: number;
10
+ /** 结束时间(秒) */
11
+ end: number;
12
+ /** 字幕文本(可包含多行) */
13
+ text: string;
14
+ }
15
+
16
+ /** 字幕轨道 */
17
+ export interface SubtitleTrack {
18
+ /** 轨道标签(如语言名称) */
19
+ label: string;
20
+ /** 语言代码 */
21
+ language?: string;
22
+ /** 字幕条目 */
23
+ cues: SubtitleCue[];
24
+ }
25
+
26
+ /**
27
+ * 解析时间字符串为秒数
28
+ * 支持格式:
29
+ * - SRT: 00:00:00,000
30
+ * - VTT: 00:00:00.000 或 00:00.000
31
+ * - ASS: 0:00:00.00
32
+ */
33
+ function parseTime(timeStr: string): number {
34
+ // 统一处理分隔符
35
+ const normalized = timeStr.trim().replace(',', '.');
36
+
37
+ // 匹配时间格式
38
+ const match = normalized.match(/^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
39
+ if (!match) return 0;
40
+
41
+ const hours = match[1] ? parseInt(match[1], 10) : 0;
42
+ const minutes = parseInt(match[2], 10);
43
+ const seconds = parseFloat(match[3]);
44
+
45
+ return hours * 3600 + minutes * 60 + seconds;
46
+ }
47
+
48
+ /**
49
+ * 解析 SRT 格式字幕
50
+ * 格式:
51
+ * 1
52
+ * 00:00:01,000 --> 00:00:04,000
53
+ * 字幕文本
54
+ */
55
+ export function parseSRT(content: string): SubtitleCue[] {
56
+ const cues: SubtitleCue[] = [];
57
+ const blocks = content.trim().split(/\n\s*\n/);
58
+
59
+ for (const block of blocks) {
60
+ const lines = block.trim().split('\n');
61
+ if (lines.length < 2) continue;
62
+
63
+ // 跳过序号行,找时间行
64
+ let timeLineIndex = 0;
65
+ if (!/-->/.test(lines[0])) {
66
+ timeLineIndex = 1;
67
+ }
68
+
69
+ const timeLine = lines[timeLineIndex];
70
+ const timeMatch = timeLine.match(/(\d+:\d+:\d+[,\.]\d+)\s*-->\s*(\d+:\d+:\d+[,\.]\d+)/);
71
+ if (!timeMatch) continue;
72
+
73
+ const start = parseTime(timeMatch[1]);
74
+ const end = parseTime(timeMatch[2]);
75
+ const text = lines.slice(timeLineIndex + 1).join('\n').trim();
76
+
77
+ if (text) {
78
+ cues.push({ start, end, text });
79
+ }
80
+ }
81
+
82
+ return cues;
83
+ }
84
+
85
+ /**
86
+ * 解析 VTT 格式字幕
87
+ * 格式:
88
+ * WEBVTT
89
+ *
90
+ * 00:00:01.000 --> 00:00:04.000
91
+ * 字幕文本
92
+ */
93
+ export function parseVTT(content: string): SubtitleCue[] {
94
+ const cues: SubtitleCue[] = [];
95
+
96
+ // 移除 WEBVTT 头部和注释
97
+ const lines = content.split('\n');
98
+ let i = 0;
99
+
100
+ // 跳过头部
101
+ while (i < lines.length && !lines[i].includes('-->')) {
102
+ i++;
103
+ }
104
+
105
+ // 解析字幕块
106
+ while (i < lines.length) {
107
+ const line = lines[i].trim();
108
+
109
+ // 查找时间行
110
+ const timeMatch = line.match(/(\d+:?\d+:\d+\.\d+)\s*-->\s*(\d+:?\d+:\d+\.\d+)/);
111
+ if (timeMatch) {
112
+ const start = parseTime(timeMatch[1]);
113
+ const end = parseTime(timeMatch[2]);
114
+
115
+ // 收集文本行
116
+ const textLines: string[] = [];
117
+ i++;
118
+ while (i < lines.length && lines[i].trim() !== '' && !lines[i].includes('-->')) {
119
+ textLines.push(lines[i].trim());
120
+ i++;
121
+ }
122
+
123
+ const text = textLines.join('\n');
124
+ if (text) {
125
+ cues.push({ start, end, text });
126
+ }
127
+ } else {
128
+ i++;
129
+ }
130
+ }
131
+
132
+ return cues;
133
+ }
134
+
135
+ /**
136
+ * 解析 ASS/SSA 格式字幕
137
+ * 格式:
138
+ * [Events]
139
+ * Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
140
+ * Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,字幕文本
141
+ */
142
+ export function parseASS(content: string): SubtitleCue[] {
143
+ const cues: SubtitleCue[] = [];
144
+ const lines = content.split('\n');
145
+
146
+ let inEvents = false;
147
+ let formatParts: string[] = [];
148
+ let textIndex = -1;
149
+ let startIndex = -1;
150
+ let endIndex = -1;
151
+
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+
155
+ // 检查是否进入 Events 部分
156
+ if (trimmed.toLowerCase() === '[events]') {
157
+ inEvents = true;
158
+ continue;
159
+ }
160
+
161
+ // 检查是否离开 Events 部分
162
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
163
+ inEvents = false;
164
+ continue;
165
+ }
166
+
167
+ if (!inEvents) continue;
168
+
169
+ // 解析 Format 行
170
+ if (trimmed.toLowerCase().startsWith('format:')) {
171
+ const formatStr = trimmed.substring(7).trim();
172
+ formatParts = formatStr.split(',').map(s => s.trim().toLowerCase());
173
+ textIndex = formatParts.indexOf('text');
174
+ startIndex = formatParts.indexOf('start');
175
+ endIndex = formatParts.indexOf('end');
176
+ continue;
177
+ }
178
+
179
+ // 解析 Dialogue 行
180
+ if (trimmed.toLowerCase().startsWith('dialogue:')) {
181
+ const dialogueStr = trimmed.substring(9).trim();
182
+
183
+ // 按逗号分割,但 Text 部分可能包含逗号
184
+ const parts: string[] = [];
185
+ let current = '';
186
+ let partCount = 0;
187
+
188
+ for (let i = 0; i < dialogueStr.length; i++) {
189
+ const char = dialogueStr[i];
190
+
191
+ if (char === ',' && partCount < formatParts.length - 1) {
192
+ parts.push(current.trim());
193
+ current = '';
194
+ partCount++;
195
+ } else {
196
+ current += char;
197
+ }
198
+ }
199
+ parts.push(current.trim());
200
+
201
+ if (startIndex >= 0 && endIndex >= 0 && textIndex >= 0) {
202
+ const start = parseTime(parts[startIndex] || '0');
203
+ const end = parseTime(parts[endIndex] || '0');
204
+ let text = parts[textIndex] || '';
205
+
206
+ // 移除 ASS 样式标签
207
+ text = text
208
+ .replace(/\{[^}]*\}/g, '') // 移除 {} 包裹的标签
209
+ .replace(/\\N/g, '\n') // 换行符
210
+ .replace(/\\n/g, '\n')
211
+ .replace(/\\h/g, ' ') // 硬空格
212
+ .trim();
213
+
214
+ if (text) {
215
+ cues.push({ start, end, text });
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // 按开始时间排序
222
+ cues.sort((a, b) => a.start - b.start);
223
+
224
+ return cues;
225
+ }
226
+
227
+ /**
228
+ * 检测字幕格式
229
+ */
230
+ export function detectSubtitleFormat(content: string): 'srt' | 'vtt' | 'ass' | 'unknown' {
231
+ const trimmed = content.trim();
232
+
233
+ if (trimmed.startsWith('WEBVTT')) {
234
+ return 'vtt';
235
+ }
236
+
237
+ if (trimmed.includes('[Script Info]') || trimmed.includes('[Events]')) {
238
+ return 'ass';
239
+ }
240
+
241
+ // SRT 通常以数字开头
242
+ if (/^\d+\s*\n/.test(trimmed)) {
243
+ return 'srt';
244
+ }
245
+
246
+ return 'unknown';
247
+ }
248
+
249
+ /**
250
+ * 自动解析字幕(根据内容自动检测格式)
251
+ */
252
+ export function parseSubtitle(content: string): SubtitleCue[] {
253
+ const format = detectSubtitleFormat(content);
254
+
255
+ switch (format) {
256
+ case 'srt':
257
+ return parseSRT(content);
258
+ case 'vtt':
259
+ return parseVTT(content);
260
+ case 'ass':
261
+ return parseASS(content);
262
+ default:
263
+ // 尝试按 SRT 解析
264
+ return parseSRT(content);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 根据文件扩展名解析字幕
270
+ */
271
+ export function parseSubtitleByExtension(content: string, extension: string): SubtitleCue[] {
272
+ const ext = extension.toLowerCase().replace('.', '');
273
+
274
+ switch (ext) {
275
+ case 'srt':
276
+ return parseSRT(content);
277
+ case 'vtt':
278
+ case 'webvtt':
279
+ return parseVTT(content);
280
+ case 'ass':
281
+ case 'ssa':
282
+ return parseASS(content);
283
+ default:
284
+ return parseSubtitle(content);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * 获取当前时间对应的字幕
290
+ */
291
+ export function getCurrentCue(cues: SubtitleCue[], currentTime: number): SubtitleCue | null {
292
+ for (const cue of cues) {
293
+ if (currentTime >= cue.start && currentTime <= cue.end) {
294
+ return cue;
295
+ }
296
+ }
297
+ return null;
298
+ }
299
+
300
+ /**
301
+ * 从文件名推断字幕语言
302
+ */
303
+ export function inferLanguageFromFilename(filename: string): { label: string; language: string } {
304
+ const name = filename.toLowerCase();
305
+
306
+ const languagePatterns: Array<{ pattern: RegExp; label: string; language: string }> = [
307
+ { pattern: /\.zh[-_]?(cn|hans?)?\./, label: '简体中文', language: 'zh-CN' },
308
+ { pattern: /\.zh[-_]?(tw|hant?)\./, label: '繁體中文', language: 'zh-TW' },
309
+ { pattern: /\.chs?\./, label: '简体中文', language: 'zh-CN' },
310
+ { pattern: /\.cht?\./, label: '繁體中文', language: 'zh-TW' },
311
+ { pattern: /\.chinese\./, label: '中文', language: 'zh' },
312
+ { pattern: /\.en(g(lish)?)?\./, label: 'English', language: 'en' },
313
+ { pattern: /\.ja(p(anese)?)?\./, label: '日本語', language: 'ja' },
314
+ { pattern: /\.ko(r(ean)?)?\./, label: '한국어', language: 'ko' },
315
+ { pattern: /\.fr(ench)?\./, label: 'Français', language: 'fr' },
316
+ { pattern: /\.de(utsch)?\./, label: 'Deutsch', language: 'de' },
317
+ { pattern: /\.es(panol)?\./, label: 'Español', language: 'es' },
318
+ ];
319
+
320
+ for (const { pattern, label, language } of languagePatterns) {
321
+ if (pattern.test(name)) {
322
+ return { label, language };
323
+ }
324
+ }
325
+
326
+ return { label: '字幕', language: '' };
327
+ }
328
+
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 格式化秒数为时间字符串
3
+ * 分钟、秒始终两位补零,小时按需显示(支持三位数如 100:00:00)
4
+ * @param seconds - 秒数
5
+ * @param forceHours - 是否强制显示小时(用于与总时长对齐宽度)
6
+ * @returns 格式化后的时间字符串,如 "08:43"、"1:02:34"、"100:00:00"
7
+ */
8
+ export function formatTime(seconds: number, forceHours = false): string {
9
+ if (seconds == null || !Number.isFinite(seconds) || seconds < 0) {
10
+ throw new Error(`formatTime 要求有效秒数,收到: ${seconds}`);
11
+ }
12
+
13
+ const h = Math.floor(seconds / 3600);
14
+ const m = Math.floor((seconds % 3600) / 60);
15
+ const s = Math.floor(seconds % 60);
16
+ const mm = String(m).padStart(2, '0');
17
+ const ss = String(s).padStart(2, '0');
18
+
19
+ if (h > 0 || forceHours) {
20
+ return `${h}:${mm}:${ss}`;
21
+ }
22
+ return `${mm}:${ss}`;
23
+ }
24
+
25
+ /**
26
+ * 格式化时间显示(当前时间 / 总时长)
27
+ * 与 formatTime 一致:总时长≥1小时时双方均按 H:MM:SS 显示
28
+ */
29
+ export function formatTimeDisplay(currentTime: number, duration: number): string {
30
+ const forceHours = Number.isFinite(duration) && duration >= 3600;
31
+ return `${formatTime(currentTime, forceHours)} / ${formatTime(duration, forceHours)}`;
32
+ }
33
+
@@ -0,0 +1,8 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue';
5
+ const component: DefineComponent<object, object, unknown>;
6
+ export default component;
7
+ }
8
+