@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,164 @@
1
+ <script setup lang="ts">
2
+ import { computed, watch } from 'vue';
3
+ import { usePopupMenu } from '../../composables/usePopupMenu';
4
+
5
+ const props = defineProps<{
6
+ /** 可选的速度列表 */
7
+ options?: number[];
8
+ /** 高亮颜色 */
9
+ activeColor?: string;
10
+ /** 父级(控制栏)是否可见,隐藏时同步关闭弹窗 */
11
+ parentVisible?: boolean;
12
+ }>();
13
+
14
+ const speed = defineModel<number>('speed', { required: true });
15
+
16
+ const { showMenu, openMenu, closeMenu } = usePopupMenu();
17
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
18
+
19
+ function scheduleClose() {
20
+ if (closeTimer) return;
21
+ closeTimer = setTimeout(() => {
22
+ closeTimer = undefined;
23
+ closeMenu();
24
+ }, 200);
25
+ }
26
+
27
+ watch(() => props.parentVisible, (v) => {
28
+ if (v === false) closeMenu();
29
+ });
30
+
31
+ const speedOptions = computed(() => props.options ?? [0.5, 0.75, 1, 1.25, 1.5, 2]);
32
+
33
+ function selectSpeed(nextSpeed: number) {
34
+ speed.value = nextSpeed;
35
+ closeMenu();
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div
41
+ class="speed-wrapper"
42
+ :class="{ 'menu-open': showMenu }"
43
+ @mouseenter="openMenu"
44
+ @mouseleave="scheduleClose"
45
+ @click.stop
46
+ >
47
+ <div class="speed-trigger">
48
+ <button
49
+ class="btn speed-btn"
50
+ :title="`播放速度: ${speed}x`"
51
+ >
52
+ <span class="speed-text">{{ speed }}x</span>
53
+ </button>
54
+
55
+ <Transition name="menu-fade">
56
+ <div v-if="showMenu" class="speed-menu">
57
+ <button
58
+ v-for="opt in speedOptions"
59
+ :key="opt"
60
+ class="speed-option"
61
+ :class="{ active: speed === opt }"
62
+ :style="speed === opt ? { color: activeColor || 'var(--huyooo-primary)' } : {}"
63
+ @click="selectSpeed(opt)"
64
+ >
65
+ {{ opt }}x
66
+ </button>
67
+ </div>
68
+ </Transition>
69
+ </div>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .speed-wrapper {
75
+ position: relative;
76
+ }
77
+
78
+ .speed-trigger {
79
+ position: relative;
80
+ }
81
+
82
+ .speed-wrapper.menu-open {
83
+ padding-top: 220px;
84
+ margin-top: -220px;
85
+ }
86
+
87
+ .btn {
88
+ width: 32px;
89
+ height: 32px;
90
+ background: transparent;
91
+ border: none;
92
+ border-radius: 6px;
93
+ cursor: pointer;
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ color: var(--huyooo-text);
98
+ transition: all 0.15s;
99
+ }
100
+
101
+ .btn:hover {
102
+ background: var(--huyooo-muted-hover);
103
+ color: var(--huyooo-on-primary, #ffffff);
104
+ }
105
+
106
+ .btn:active {
107
+ transform: scale(0.95);
108
+ }
109
+
110
+ .speed-text {
111
+ font-size: 11px;
112
+ font-weight: 600;
113
+ line-height: 1;
114
+ }
115
+
116
+ .speed-menu {
117
+ position: absolute;
118
+ bottom: 100%;
119
+ right: 0;
120
+ margin-bottom: 4px;
121
+ background: var(--huyooo-panel-bg);
122
+ backdrop-filter: blur(20px);
123
+ border-radius: 10px;
124
+ padding: 6px;
125
+ min-width: 90px;
126
+ border: 1px solid var(--huyooo-border);
127
+ box-shadow: var(--huyooo-shadow-lg);
128
+ }
129
+
130
+ .speed-option {
131
+ display: block;
132
+ width: 100%;
133
+ padding: 8px 12px;
134
+ background: transparent;
135
+ border: none;
136
+ color: var(--huyooo-text);
137
+ font-size: 13px;
138
+ text-align: left;
139
+ border-radius: 6px;
140
+ cursor: pointer;
141
+ transition: background 0.15s;
142
+ }
143
+
144
+ .speed-option:hover {
145
+ background: var(--huyooo-muted-hover);
146
+ }
147
+
148
+ .speed-option.active {
149
+ font-weight: 600;
150
+ }
151
+
152
+ /* 菜单过渡动画 */
153
+ .menu-fade-enter-active,
154
+ .menu-fade-leave-active {
155
+ transition: opacity 0.15s ease, transform 0.15s ease;
156
+ }
157
+
158
+ .menu-fade-enter-from,
159
+ .menu-fade-leave-to {
160
+ opacity: 0;
161
+ transform: translateY(6px);
162
+ }
163
+ </style>
164
+
@@ -0,0 +1,235 @@
1
+ <script setup lang="ts">
2
+ import { Icon } from '@iconify/vue';
3
+ import { watch } from 'vue';
4
+ import type { SubtitleTrack } from '../../utils/subtitle';
5
+ import { usePopupMenu } from '../../composables/usePopupMenu';
6
+
7
+ const props = defineProps<{
8
+ /** 字幕轨道列表 */
9
+ tracks: SubtitleTrack[];
10
+ /** 高亮颜色 */
11
+ activeColor?: string;
12
+ /** 父级(控制栏)是否可见,隐藏时同步关闭弹窗 */
13
+ parentVisible?: boolean;
14
+ }>();
15
+
16
+ const currentTrack = defineModel<number>('currentTrack', { required: true });
17
+
18
+ const emit = defineEmits<{
19
+ /** 加载字幕文件 */
20
+ loadSubtitle: [];
21
+ }>();
22
+
23
+ const { showMenu, openMenu, closeMenu } = usePopupMenu();
24
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
25
+
26
+ function scheduleClose() {
27
+ if (closeTimer) return;
28
+ closeTimer = setTimeout(() => {
29
+ closeTimer = undefined;
30
+ closeMenu();
31
+ }, 200);
32
+ }
33
+
34
+ watch(() => props.parentVisible, (v) => {
35
+ if (v === false) closeMenu();
36
+ });
37
+
38
+ /** 选择轨道 */
39
+ function selectTrack(index: number) {
40
+ currentTrack.value = index;
41
+ closeMenu();
42
+ }
43
+
44
+ /** 加载字幕 */
45
+ function handleLoadSubtitle() {
46
+ emit('loadSubtitle');
47
+ closeMenu();
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div
53
+ class="subtitle-wrapper"
54
+ :class="{ 'menu-open': showMenu }"
55
+ @mouseenter="openMenu"
56
+ @mouseleave="scheduleClose"
57
+ @click.stop
58
+ >
59
+ <div class="subtitle-trigger">
60
+ <button
61
+ class="btn"
62
+ :class="{ active: currentTrack >= 0 }"
63
+ title="字幕"
64
+ >
65
+ <Icon icon="lucide:captions" />
66
+ </button>
67
+
68
+ <Transition name="menu-fade">
69
+ <div v-if="showMenu" class="subtitle-menu">
70
+ <!-- 关闭字幕选项 -->
71
+ <button
72
+ class="menu-option"
73
+ :class="{ active: currentTrack === -1 }"
74
+ :style="currentTrack === -1 ? { color: activeColor || 'var(--huyooo-primary)' } : {}"
75
+ @click="selectTrack(-1)"
76
+ >
77
+ <span class="check-mark">
78
+ <Icon v-if="currentTrack === -1" icon="lucide:check" />
79
+ </span>
80
+ <span>关闭</span>
81
+ </button>
82
+
83
+ <!-- 字幕轨道列表 -->
84
+ <button
85
+ v-for="(track, index) in tracks"
86
+ :key="index"
87
+ class="menu-option"
88
+ :class="{ active: currentTrack === index }"
89
+ :style="currentTrack === index ? { color: activeColor || 'var(--huyooo-primary)' } : {}"
90
+ @click="selectTrack(index)"
91
+ >
92
+ <span class="check-mark">
93
+ <Icon v-if="currentTrack === index" icon="lucide:check" />
94
+ </span>
95
+ <span>{{ track.label }}</span>
96
+ </button>
97
+
98
+ <!-- 分隔线 -->
99
+ <div class="menu-divider" />
100
+
101
+ <!-- 加载字幕按钮 -->
102
+ <button class="menu-option load-option" @click="handleLoadSubtitle">
103
+ <span class="check-mark">
104
+ <Icon icon="lucide:plus" />
105
+ </span>
106
+ <span>加载字幕...</span>
107
+ </button>
108
+ </div>
109
+ </Transition>
110
+ </div>
111
+ </div>
112
+ </template>
113
+
114
+ <style scoped>
115
+ .subtitle-wrapper {
116
+ position: relative;
117
+ }
118
+
119
+ .subtitle-trigger {
120
+ position: relative;
121
+ }
122
+
123
+ .subtitle-wrapper.menu-open {
124
+ padding-top: 280px;
125
+ margin-top: -280px;
126
+ }
127
+
128
+ .btn {
129
+ width: 32px;
130
+ height: 32px;
131
+ background: transparent;
132
+ border: none;
133
+ border-radius: 6px;
134
+ cursor: pointer;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ color: var(--huyooo-text);
139
+ transition: all 0.15s;
140
+ }
141
+
142
+ .btn:hover {
143
+ background: var(--huyooo-muted-hover);
144
+ color: var(--huyooo-on-primary, #ffffff);
145
+ }
146
+
147
+ .btn:active {
148
+ transform: scale(0.95);
149
+ }
150
+
151
+ .btn.active {
152
+ color: var(--huyooo-primary);
153
+ }
154
+
155
+ .btn :deep(.iconify) {
156
+ font-size: 18px;
157
+ }
158
+
159
+ .subtitle-menu {
160
+ position: absolute;
161
+ bottom: 100%;
162
+ right: 0;
163
+ margin-bottom: 4px;
164
+ background: var(--huyooo-panel-bg);
165
+ backdrop-filter: blur(20px);
166
+ border-radius: 10px;
167
+ padding: 6px;
168
+ min-width: 140px;
169
+ border: 1px solid var(--huyooo-border);
170
+ box-shadow: var(--huyooo-shadow-lg);
171
+ }
172
+
173
+ .menu-option {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 8px;
177
+ width: 100%;
178
+ padding: 8px 12px;
179
+ background: transparent;
180
+ border: none;
181
+ color: var(--huyooo-text);
182
+ font-size: 13px;
183
+ text-align: left;
184
+ border-radius: 6px;
185
+ cursor: pointer;
186
+ transition: background 0.15s;
187
+ }
188
+
189
+ .menu-option:hover {
190
+ background: var(--huyooo-muted-hover);
191
+ }
192
+
193
+ .menu-option.active {
194
+ font-weight: 600;
195
+ }
196
+
197
+ .check-mark {
198
+ width: 16px;
199
+ height: 16px;
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ flex-shrink: 0;
204
+ }
205
+
206
+ .check-mark :deep(.iconify) {
207
+ font-size: 14px;
208
+ }
209
+
210
+ .load-option {
211
+ color: var(--huyooo-text-muted);
212
+ }
213
+
214
+ .load-option .check-mark {
215
+ color: var(--huyooo-text-disabled);
216
+ }
217
+
218
+ .menu-divider {
219
+ height: 1px;
220
+ margin: 4px 6px;
221
+ background: var(--huyooo-border);
222
+ }
223
+
224
+ /* 菜单过渡动画 */
225
+ .menu-fade-enter-active,
226
+ .menu-fade-leave-active {
227
+ transition: opacity 0.15s ease, transform 0.15s ease;
228
+ }
229
+
230
+ .menu-fade-enter-from,
231
+ .menu-fade-leave-to {
232
+ opacity: 0;
233
+ transform: translateY(6px);
234
+ }
235
+ </style>
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { SubtitleCue } from '../../utils/subtitle';
4
+
5
+ const props = defineProps<{
6
+ /** 当前字幕 */
7
+ cue: SubtitleCue | null;
8
+ /** 是否显示字幕 */
9
+ visible: boolean;
10
+ /** 字幕字体大小 */
11
+ fontSize?: number;
12
+ }>();
13
+
14
+ const fontSize = computed(() => props.fontSize || 24);
15
+
16
+ /** 处理字幕文本,转换换行符为 HTML */
17
+ const formattedText = computed(() => {
18
+ if (!props.cue?.text) return '';
19
+ return props.cue.text
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/\n/g, '<br>');
23
+ });
24
+ </script>
25
+
26
+ <template>
27
+ <Transition name="subtitle-fade">
28
+ <div
29
+ v-if="visible && cue"
30
+ class="subtitle-overlay"
31
+ :style="{ '--subtitle-font-size': fontSize + 'px' }"
32
+ >
33
+ <div class="subtitle-text" v-html="formattedText" />
34
+ </div>
35
+ </Transition>
36
+ </template>
37
+
38
+ <style scoped>
39
+ .subtitle-overlay {
40
+ position: absolute;
41
+ bottom: 80px;
42
+ left: 0;
43
+ right: 0;
44
+ display: flex;
45
+ justify-content: center;
46
+ padding: 0 40px;
47
+ pointer-events: none;
48
+ z-index: 10;
49
+ }
50
+
51
+ .subtitle-text {
52
+ max-width: 80%;
53
+ padding: 8px 16px;
54
+ background: var(--huyooo-panel-bg);
55
+ backdrop-filter: blur(20px);
56
+ border-radius: 10px;
57
+ border: 1px solid var(--huyooo-border);
58
+ color: var(--huyooo-text);
59
+ font-size: var(--subtitle-font-size);
60
+ font-weight: 500;
61
+ line-height: 1.4;
62
+ text-align: center;
63
+ box-shadow: var(--huyooo-shadow-lg);
64
+ white-space: pre-wrap;
65
+ word-break: break-word;
66
+ }
67
+
68
+ /* 淡入淡出动画 */
69
+ .subtitle-fade-enter-active,
70
+ .subtitle-fade-leave-active {
71
+ transition: opacity 0.15s ease;
72
+ }
73
+
74
+ .subtitle-fade-enter-from,
75
+ .subtitle-fade-leave-to {
76
+ opacity: 0;
77
+ }
78
+ </style>
79
+
@@ -0,0 +1,235 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
3
+ import { Icon } from '@iconify/vue';
4
+ import { useSliderDrag } from '../../composables/useSliderDrag';
5
+
6
+ const props = defineProps<{
7
+ /** 当前音量 (0-1) */
8
+ volume: number;
9
+ /** 是否静音 */
10
+ muted: boolean;
11
+ /** 父级(控制栏)是否可见,隐藏时同步关闭弹窗 */
12
+ parentVisible?: boolean;
13
+ }>();
14
+
15
+ const emit = defineEmits<{
16
+ 'update:volume': [volume: number];
17
+ toggleMute: [];
18
+ }>();
19
+
20
+ const volumeTrackRef = ref<HTMLElement>();
21
+ const showMenu = ref(false);
22
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
23
+
24
+ function openMenu() {
25
+ if (closeTimer) {
26
+ clearTimeout(closeTimer);
27
+ closeTimer = undefined;
28
+ }
29
+ showMenu.value = true;
30
+ }
31
+
32
+ function closeMenu() {
33
+ if (closeTimer) {
34
+ clearTimeout(closeTimer);
35
+ closeTimer = undefined;
36
+ }
37
+ showMenu.value = false;
38
+ }
39
+
40
+ /** 悬停离开时延迟关闭,便于移动到弹窗内 */
41
+ function scheduleClose() {
42
+ if (closeTimer) return;
43
+ closeTimer = setTimeout(closeMenu, 200);
44
+ }
45
+
46
+ /** 父级隐藏时立即关闭 */
47
+ watch(() => props.parentVisible, (v) => {
48
+ if (v === false) closeMenu();
49
+ });
50
+
51
+ /** 点击外部关闭(与 SpeedMenu/SubtitleMenu 一致) */
52
+ onMounted(() => {
53
+ document.addEventListener('click', closeMenu);
54
+ });
55
+ onUnmounted(() => {
56
+ document.removeEventListener('click', closeMenu);
57
+ });
58
+
59
+ const { isDragging, handleMouseDown, handleTouchStart } = useSliderDrag({
60
+ getElement: () => volumeTrackRef.value,
61
+ onChange: (percent) => emit('update:volume', percent),
62
+ thumbRadius: 6,
63
+ orientation: 'vertical',
64
+ });
65
+
66
+ /** 音量图标 */
67
+ const volumeIcon = computed(() => {
68
+ if (props.muted || props.volume <= 0) return 'lucide:volume-x';
69
+ if (props.volume > 0.5) return 'lucide:volume-2';
70
+ return 'lucide:volume-1';
71
+ });
72
+
73
+ /** 音量百分比 */
74
+ const volumePercent = computed(() => Math.round(props.volume * 100));
75
+ </script>
76
+
77
+ <template>
78
+ <div
79
+ class="volume-wrapper"
80
+ :class="{ 'menu-open': showMenu }"
81
+ @mouseenter="openMenu"
82
+ @mouseleave="scheduleClose"
83
+ @click.stop
84
+ >
85
+ <div class="volume-trigger">
86
+ <button
87
+ class="btn"
88
+ :title="muted ? '取消静音 (M)' : `音量 ${volumePercent}% (M)`"
89
+ @click="emit('toggleMute')"
90
+ >
91
+ <Icon :icon="volumeIcon" />
92
+ </button>
93
+
94
+ <Transition name="menu-fade">
95
+ <div v-if="showMenu" class="volume-popover">
96
+ <span class="volume-value">{{ volumePercent }}%</span>
97
+ <div
98
+ ref="volumeTrackRef"
99
+ class="volume-track"
100
+ :class="{ dragging: isDragging }"
101
+ @mousedown="handleMouseDown"
102
+ @touchstart="handleTouchStart"
103
+ >
104
+ <div class="volume-progress" :style="{ height: (volume * 100) + '%' }" />
105
+ <div class="volume-thumb" :style="{ bottom: (volume * 100) + '%' }" />
106
+ </div>
107
+ </div>
108
+ </Transition>
109
+ </div>
110
+ </div>
111
+ </template>
112
+
113
+ <style scoped>
114
+ .volume-wrapper {
115
+ position: relative;
116
+ }
117
+
118
+ /* 定位基准:仅按钮高度,bottom:100% 不会因 padding 变大 */
119
+ .volume-trigger {
120
+ position: relative;
121
+ }
122
+
123
+ /* 弹窗打开时扩展悬停区域,便于从按钮移动到弹窗 */
124
+ .volume-wrapper.menu-open {
125
+ padding-top: 96px;
126
+ margin-top: -96px;
127
+ }
128
+
129
+ .btn {
130
+ width: 32px;
131
+ height: 32px;
132
+ background: transparent;
133
+ border: none;
134
+ border-radius: 6px;
135
+ cursor: pointer;
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ color: var(--huyooo-text);
140
+ transition: all 0.15s;
141
+ }
142
+
143
+ .btn:hover {
144
+ background: var(--huyooo-muted-hover);
145
+ color: var(--huyooo-on-primary, #ffffff);
146
+ }
147
+
148
+ .btn:active {
149
+ transform: scale(0.95);
150
+ }
151
+
152
+ .btn :deep(.iconify) {
153
+ font-size: 18px;
154
+ }
155
+
156
+ .volume-popover {
157
+ position: absolute;
158
+ bottom: 100%;
159
+ left: 50%;
160
+ transform: translateX(-50%);
161
+ margin-bottom: 4px;
162
+ display: flex;
163
+ flex-direction: column;
164
+ align-items: center;
165
+ gap: 8px;
166
+ padding: 10px 8px;
167
+ background: var(--huyooo-panel-bg);
168
+ backdrop-filter: blur(20px);
169
+ border-radius: 10px;
170
+ border: 1px solid var(--huyooo-border);
171
+ box-shadow: var(--huyooo-shadow-lg);
172
+ }
173
+
174
+ .volume-value {
175
+ font-size: 11px;
176
+ font-weight: 600;
177
+ color: var(--huyooo-text);
178
+ font-variant-numeric: tabular-nums;
179
+ }
180
+
181
+ .volume-track {
182
+ width: 4px;
183
+ height: 80px;
184
+ background: color-mix(in srgb, var(--huyooo-text) 20%, transparent);
185
+ border-radius: 2px;
186
+ position: relative;
187
+ cursor: pointer;
188
+ user-select: none;
189
+ transition: width 0.15s;
190
+ }
191
+
192
+ .volume-track:hover,
193
+ .volume-track.dragging {
194
+ width: 6px;
195
+ }
196
+
197
+ .volume-progress {
198
+ position: absolute;
199
+ left: 0;
200
+ bottom: 0;
201
+ width: 100%;
202
+ background: var(--huyooo-primary);
203
+ border-radius: 2px;
204
+ pointer-events: none;
205
+ }
206
+
207
+ .volume-thumb {
208
+ position: absolute;
209
+ left: 50%;
210
+ width: 10px;
211
+ height: 10px;
212
+ background: var(--huyooo-on-primary, #fff);
213
+ border-radius: 50%;
214
+ transform: translate(-50%, 50%) scale(0);
215
+ pointer-events: none;
216
+ box-shadow: var(--huyooo-shadow-sm);
217
+ transition: transform 0.15s;
218
+ }
219
+
220
+ .volume-track:hover .volume-thumb,
221
+ .volume-track.dragging .volume-thumb {
222
+ transform: translate(-50%, 50%) scale(1);
223
+ }
224
+
225
+ .menu-fade-enter-active,
226
+ .menu-fade-leave-active {
227
+ transition: opacity 0.15s ease, transform 0.15s ease;
228
+ }
229
+
230
+ .menu-fade-enter-from,
231
+ .menu-fade-leave-to {
232
+ opacity: 0;
233
+ transform: translateX(-50%) translateY(6px);
234
+ }
235
+ </style>