@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.
package/package.json CHANGED
@@ -1,23 +1,22 @@
1
1
  {
2
2
  "name": "@huyooo/file-explorer-preview",
3
- "version": "0.4.29",
4
- "description": "Media preview window for File Explorer - supports image, video, and audio",
3
+ "version": "0.4.30",
4
+ "description": "Media preview components for File Explorer - ImageViewer, VideoPlayer, AudioPlayer",
5
5
  "type": "module",
6
- "main": "./dist/index.html",
7
6
  "exports": {
8
- ".": "./dist/index.html",
9
- "./path": {
10
- "types": "./dist/path.d.ts",
11
- "import": "./dist/path.js"
7
+ ".": {
8
+ "import": "./src/index.ts"
9
+ },
10
+ "./components": {
11
+ "import": "./src/components/index.ts"
12
12
  }
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "src"
16
16
  ],
17
17
  "scripts": {
18
- "build": "vite build && tsup",
19
- "dev": "vite",
20
- "preview": "vite preview",
18
+ "build": "npm run typecheck",
19
+ "typecheck": "tsc --noEmit",
21
20
  "clean": "rm -rf dist"
22
21
  },
23
22
  "dependencies": {
@@ -26,11 +25,7 @@
26
25
  "vue": "^3.5.0"
27
26
  },
28
27
  "devDependencies": {
29
- "@vitejs/plugin-vue": "^5.0.0",
30
- "terser": "^5.44.1",
31
- "tsup": "^8.0.0",
32
- "typescript": "^5.0.0",
33
- "vite": "^6.0.0"
28
+ "typescript": "^5.0.0"
34
29
  },
35
30
  "keywords": [
36
31
  "file",
@@ -40,7 +35,8 @@
40
35
  "video",
41
36
  "audio",
42
37
  "image",
43
- "electron"
38
+ "vue",
39
+ "component"
44
40
  ],
45
41
  "author": "huyooo",
46
42
  "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,349 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 媒体预览页面(完整壳:标题栏 + 媒体组件)
4
+ *
5
+ * app 直接显示,媒体组件自带 loading,单一加载体验
6
+ *
7
+ * URL 参数:type=image|video|audio&url=...&name=...&platform=darwin|win32|linux
8
+ *
9
+ * 偏好:通过 mediaPreferences prop 传入,宿主应用负责从 DB 加载
10
+ */
11
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
12
+ import { ImageViewer, VideoPlayer, AudioPlayer } from './components/index'
13
+ import { Icon } from '@iconify/vue'
14
+ import { DEFAULT_PREVIEW_MEDIA_PREFERENCES, type PreviewMediaPreferences } from './types/previewMediaPreferences'
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ mediaPreferences?: PreviewMediaPreferences
19
+ }>(),
20
+ { mediaPreferences: () => DEFAULT_PREVIEW_MEDIA_PREFERENCES },
21
+ )
22
+
23
+ const prefs = computed(() => props.mediaPreferences ?? DEFAULT_PREVIEW_MEDIA_PREFERENCES)
24
+
25
+ type MediaType = 'image' | 'video' | 'audio'
26
+ type Platform = 'darwin' | 'win32' | 'linux'
27
+
28
+ const emit = defineEmits<{
29
+ ready: []
30
+ 'update:imageFitMode': [value: 'fit' | 'actual']
31
+ 'update:volume': [value: number]
32
+ 'update:muted': [value: boolean]
33
+ 'update:playbackRate': [value: number]
34
+ 'update:loop': [value: boolean]
35
+ }>()
36
+
37
+ const mediaType = ref<MediaType>('video')
38
+ const fileUrl = ref('')
39
+ const fileName = ref('')
40
+ const paramsError = ref('')
41
+ const platform = ref<Platform>('darwin')
42
+ const isMaximized = ref(false)
43
+ const readyEmitted = ref(false)
44
+
45
+ const isMac = computed(() => platform.value === 'darwin')
46
+ const isWindows = computed(() => platform.value === 'win32')
47
+
48
+ function emitReady() {
49
+ if (readyEmitted.value) return
50
+ readyEmitted.value = true
51
+ emit('ready')
52
+ }
53
+
54
+ function minimizeWindow() {
55
+ window.electronAPI?.minimizeWindow?.()
56
+ }
57
+
58
+ function toggleMaximize() {
59
+ window.electronAPI?.toggleMaximizeWindow?.()
60
+ }
61
+
62
+ function closeWindow() {
63
+ window.electronAPI?.closeWindow?.()
64
+ }
65
+
66
+ function ensureMediaUrl(urlOrPath: string): string {
67
+ if (urlOrPath.startsWith('app://') || urlOrPath.startsWith('file://')) return urlOrPath
68
+ const segments = urlOrPath.split('/').map((s) => encodeURIComponent(s))
69
+ return `app://file${segments.join('/')}`
70
+ }
71
+
72
+ let unsubscribeMaximize: (() => void) | undefined
73
+
74
+ onMounted(() => {
75
+ const params = new URLSearchParams(window.location.search)
76
+ const type = params.get('type')
77
+ const url = params.get('url')
78
+ const name = params.get('name')
79
+ const platformParam = params.get('platform')
80
+
81
+ document.documentElement.setAttribute('data-theme', 'dark')
82
+
83
+ if (!type || !['image', 'video', 'audio'].includes(type) || !url) {
84
+ paramsError.value = '参数错误:缺少必要的媒体信息'
85
+ emitReady()
86
+ return
87
+ }
88
+
89
+ mediaType.value = type as MediaType
90
+ fileUrl.value = ensureMediaUrl(decodeURIComponent(url))
91
+ fileName.value = name ? decodeURIComponent(name) : '未知文件'
92
+ platform.value = (['darwin', 'win32', 'linux'].includes(platformParam ?? '') ? platformParam : 'darwin') as Platform
93
+ document.title = fileName.value
94
+
95
+ unsubscribeMaximize = window.electronAPI?.onMaximizeChange?.((maximized: boolean) => {
96
+ isMaximized.value = maximized
97
+ })
98
+ })
99
+
100
+ onUnmounted(() => {
101
+ unsubscribeMaximize?.()
102
+ })
103
+
104
+ function handleMediaLoaded() {
105
+ emitReady()
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <div class="preview-app" :class="{ 'platform-mac': isMac, 'platform-windows': isWindows }">
111
+ <!-- 标题栏 -->
112
+ <div class="titlebar" :class="{ 'titlebar-mac': isMac, 'titlebar-windows': isWindows }">
113
+ <div v-if="isMac" class="titlebar-content mac">
114
+ <span class="window-title">{{ fileName }}</span>
115
+ </div>
116
+ <div v-else class="titlebar-content windows">
117
+ <span class="window-title">{{ fileName }}</span>
118
+ <div class="window-controls">
119
+ <button class="control-btn minimize" @click="minimizeWindow" title="最小化">
120
+ <Icon icon="lucide:minus" />
121
+ </button>
122
+ <button class="control-btn maximize" @click="toggleMaximize" :title="isMaximized ? '还原' : '最大化'">
123
+ <Icon :icon="isMaximized ? 'lucide:square' : 'lucide:maximize'" />
124
+ </button>
125
+ <button class="control-btn close" @click="closeWindow" title="关闭">
126
+ <Icon icon="lucide:x" />
127
+ </button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- 参数错误 -->
133
+ <div v-if="paramsError" class="error-state">
134
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
135
+ <circle cx="12" cy="12" r="10" />
136
+ <line x1="15" y1="9" x2="9" y2="15" />
137
+ <line x1="9" y1="9" x2="15" y2="15" />
138
+ </svg>
139
+ <p>{{ paramsError }}</p>
140
+ </div>
141
+
142
+ <!-- 内容区:媒体组件自带 loading -->
143
+ <div v-else class="content-area">
144
+ <div class="media-container">
145
+ <ImageViewer v-if="mediaType === 'image'" :url="fileUrl" :name="fileName"
146
+ :initial-image-fit-mode="prefs.imageFitMode"
147
+ @loaded="handleMediaLoaded"
148
+ @update:image-fit-mode="(v) => emit('update:imageFitMode', v)" />
149
+ <VideoPlayer v-else-if="mediaType === 'video'" :url="fileUrl" :name="fileName"
150
+ :initial-volume="prefs.volume" :initial-muted="prefs.muted" :initial-playback-rate="prefs.playbackRate"
151
+ :initial-loop="prefs.loop ?? false"
152
+ @loaded="handleMediaLoaded"
153
+ @update:volume="(v) => emit('update:volume', v)"
154
+ @update:muted="(v) => emit('update:muted', v)"
155
+ @update:playback-rate="(v) => emit('update:playbackRate', v)"
156
+ @update:loop="(v) => emit('update:loop', v)" />
157
+ <AudioPlayer v-else :url="fileUrl" :name="fileName"
158
+ :initial-volume="prefs.volume" :initial-muted="prefs.muted" :initial-playback-rate="prefs.playbackRate"
159
+ :initial-loop="prefs.loop ?? false"
160
+ @loaded="handleMediaLoaded"
161
+ @update:volume="(v) => emit('update:volume', v)"
162
+ @update:muted="(v) => emit('update:muted', v)"
163
+ @update:playback-rate="(v) => emit('update:playbackRate', v)"
164
+ @update:loop="(v) => emit('update:loop', v)" />
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </template>
169
+
170
+ <style scoped>
171
+ /* 预览窗口固定暗色主题 token */
172
+ .preview-app {
173
+ --huyooo-bg: #000000;
174
+ --huyooo-surface: #000000;
175
+ --huyooo-surface-2: #252526;
176
+ --huyooo-muted: #2d2d2d;
177
+ --huyooo-muted-hover: #3c3c3c;
178
+ --huyooo-border: #333;
179
+ --huyooo-text: #ccc;
180
+ --huyooo-text-muted: #888;
181
+ --huyooo-text-disabled: #666;
182
+ --huyooo-on-primary: #ffffff;
183
+ --huyooo-on-primary-muted: rgba(255, 255, 255, 0.8);
184
+ --huyooo-primary: #2563eb;
185
+ --huyooo-focus-ring: rgba(37, 99, 235, 0.18);
186
+ --huyooo-scrollbar: rgba(255, 255, 255, 0.2);
187
+ --huyooo-scrollbar-hover: rgba(255, 255, 255, 0.3);
188
+ --huyooo-overlay: rgba(0, 0, 0, 0.6);
189
+ --huyooo-overlay-strong: rgba(0, 0, 0, 0.85);
190
+ --huyooo-panel-bg: #252526;
191
+ --huyooo-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.35);
192
+ --huyooo-shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
193
+ --huyooo-danger: #ef4444;
194
+ --huyooo-success: #22c55e;
195
+ --huyooo-warning: #f59e0b;
196
+ --huyooo-win-close-hover: #e81123;
197
+ --huyooo-win-close-active: #bf0f1d;
198
+
199
+ width: 100%;
200
+ min-width: 720px;
201
+ height: 100vh;
202
+ position: relative;
203
+ display: flex;
204
+ flex-direction: column;
205
+ background: var(--huyooo-bg);
206
+ color: var(--huyooo-text);
207
+ overflow: hidden;
208
+ user-select: none;
209
+ color-scheme: dark;
210
+ }
211
+
212
+ /* ========================
213
+ 标题栏
214
+ ======================== */
215
+ .titlebar {
216
+ position: absolute;
217
+ top: 0;
218
+ left: 0;
219
+ right: 0;
220
+ z-index: 1000;
221
+ -webkit-app-region: drag;
222
+ background: var(--huyooo-panel-bg);
223
+ backdrop-filter: blur(20px);
224
+ -webkit-backdrop-filter: blur(20px);
225
+ border-bottom: 1px solid var(--huyooo-border);
226
+ }
227
+
228
+ .titlebar-content {
229
+ display: flex;
230
+ align-items: center;
231
+ height: 100%;
232
+ }
233
+
234
+ .window-title {
235
+ font-size: 13px;
236
+ font-weight: 500;
237
+ color: var(--huyooo-text);
238
+ text-overflow: ellipsis;
239
+ white-space: nowrap;
240
+ overflow: hidden;
241
+ }
242
+
243
+ .titlebar-mac {
244
+ height: 38px;
245
+ }
246
+
247
+ .titlebar-content.mac {
248
+ justify-content: center;
249
+ padding-left: 80px;
250
+ padding-right: 80px;
251
+ }
252
+
253
+ .titlebar-mac .window-title {
254
+ max-width: 300px;
255
+ }
256
+
257
+ .titlebar-windows {
258
+ height: 32px;
259
+ }
260
+
261
+ .titlebar-content.windows {
262
+ justify-content: space-between;
263
+ padding-left: 12px;
264
+ }
265
+
266
+ .titlebar-windows .window-title {
267
+ max-width: 400px;
268
+ }
269
+
270
+ .window-controls {
271
+ display: flex;
272
+ height: 100%;
273
+ -webkit-app-region: no-drag;
274
+ }
275
+
276
+ .control-btn {
277
+ width: 46px;
278
+ height: 100%;
279
+ border: none;
280
+ background: transparent;
281
+ color: var(--huyooo-text-muted);
282
+ cursor: pointer;
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: center;
286
+ transition: background 0.15s, color 0.15s;
287
+ font-size: 16px;
288
+ }
289
+
290
+ .control-btn:hover {
291
+ background: var(--huyooo-muted-hover);
292
+ color: var(--huyooo-text);
293
+ }
294
+
295
+ .control-btn.close:hover {
296
+ background: var(--huyooo-win-close-hover);
297
+ color: var(--huyooo-on-primary);
298
+ }
299
+
300
+ .control-btn:active {
301
+ background: var(--huyooo-muted);
302
+ }
303
+
304
+ .control-btn.close:active {
305
+ background: var(--huyooo-win-close-active);
306
+ }
307
+
308
+ /* ========================
309
+ 内容区域
310
+ ======================== */
311
+ .content-area,
312
+ .error-state {
313
+ flex: 1;
314
+ width: 100%;
315
+ height: 100%;
316
+ padding-top: 38px;
317
+ overflow: hidden;
318
+ position: relative;
319
+ }
320
+
321
+ .platform-windows .content-area,
322
+ .platform-windows .error-state {
323
+ padding-top: 32px;
324
+ }
325
+
326
+ .media-container {
327
+ position: absolute;
328
+ inset: 0;
329
+ }
330
+
331
+ .error-state {
332
+ display: flex;
333
+ flex-direction: column;
334
+ align-items: center;
335
+ justify-content: center;
336
+ gap: 16px;
337
+ }
338
+
339
+ .error-state svg {
340
+ width: 48px;
341
+ height: 48px;
342
+ color: var(--huyooo-danger);
343
+ }
344
+
345
+ .error-state p {
346
+ color: var(--huyooo-text);
347
+ font-size: 14px;
348
+ }
349
+ </style>
@@ -0,0 +1,295 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, onUnmounted } from 'vue';
3
+ import { Icon } from '@iconify/vue';
4
+ import { useMediaPlayer } from '../composables/useMediaPlayer';
5
+ import { useFullscreen } from '../composables/useFullscreen';
6
+ import PlayerControls from './ui/PlayerControls.vue';
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ url: string;
11
+ name: string;
12
+ initialVolume?: number;
13
+ initialMuted?: boolean;
14
+ initialPlaybackRate?: number;
15
+ initialLoop?: boolean;
16
+ }>(),
17
+ { initialVolume: 1, initialMuted: false, initialPlaybackRate: 1, initialLoop: false },
18
+ );
19
+
20
+ const emit = defineEmits<{
21
+ loaded: [];
22
+ 'update:volume': [value: number];
23
+ 'update:muted': [value: boolean];
24
+ 'update:playbackRate': [value: number];
25
+ 'update:loop': [value: boolean];
26
+ }>();
27
+
28
+ const audioRef = ref<HTMLAudioElement>();
29
+ const playerRef = ref<HTMLDivElement>();
30
+
31
+ // 使用媒体播放器 composable
32
+ const media = useMediaPlayer(() => audioRef.value, {
33
+ initialVolume: props.initialVolume,
34
+ initialMuted: props.initialMuted,
35
+ initialPlaybackRate: props.initialPlaybackRate,
36
+ initialLoop: props.initialLoop,
37
+ });
38
+
39
+ const { isFullscreen, toggleFullscreen } = useFullscreen(() => playerRef.value);
40
+
41
+ // 跨窗口同步:props 变化时同步到播放器
42
+ watch(
43
+ () => [props.initialVolume, props.initialMuted, props.initialPlaybackRate, props.initialLoop] as const,
44
+ ([vol, mut, rate, loopVal]) => {
45
+ if (vol !== undefined) media.setVolume(vol);
46
+ if (mut !== undefined) media.setMuted(mut);
47
+ if (rate !== undefined) media.setSpeed(rate);
48
+ if (loopVal !== undefined) media.setLoop(loopVal);
49
+ },
50
+ );
51
+
52
+ /** 进度条变化 */
53
+ function handleProgressChange(percent: number) {
54
+ media.setPauseTimeUpdate(true);
55
+ media.seekToPercent(percent);
56
+ setTimeout(() => media.setPauseTimeUpdate(false), 100);
57
+ }
58
+
59
+ /** 音量变化(拖拽时 setVolume 会取消静音) */
60
+ function handleVolumeChange(volume: number) {
61
+ media.setVolume(volume);
62
+ emit('update:volume', media.volume.value);
63
+ emit('update:muted', media.isMuted.value);
64
+ }
65
+
66
+ /** 静音切换 */
67
+ function handleMuteToggle() {
68
+ media.toggleMute();
69
+ emit('update:muted', media.isMuted.value);
70
+ }
71
+
72
+ /** 速度变化 */
73
+ function handleSpeedChange(speed: number) {
74
+ media.setSpeed(speed);
75
+ emit('update:playbackRate', media.playbackRate.value);
76
+ }
77
+
78
+ /** 循环切换 */
79
+ function handleLoopToggle() {
80
+ media.toggleLoop();
81
+ emit('update:loop', media.loop.value);
82
+ }
83
+
84
+ function handlePlayerDoubleClick(e: MouseEvent) {
85
+ const target = e.target;
86
+ if (target instanceof Element && target.closest('.controls-wrapper')) return;
87
+ void toggleFullscreen();
88
+ }
89
+
90
+ /** 媒体就绪时通知父组件(骨架屏关闭) */
91
+ const hasEmittedLoaded = ref(false);
92
+ watch(
93
+ () => [media.isLoading.value, media.hasError.value] as const,
94
+ ([loading, error]) => {
95
+ if ((!loading || error) && !hasEmittedLoaded.value) {
96
+ hasEmittedLoaded.value = true;
97
+ emit('loaded');
98
+ }
99
+ },
100
+ { immediate: true },
101
+ );
102
+
103
+ /** 宿主在窗口可见时派发,此时播放可绕过 Chromium 后台节流;宿主必须派发,否则不自动播放 */
104
+ const PREVIEW_READY_EVENT = 'preview-ready-to-show';
105
+
106
+ onMounted(() => {
107
+ const playOnce = () => media.autoPlay();
108
+ window.addEventListener(PREVIEW_READY_EVENT, playOnce);
109
+ onUnmounted(() => window.removeEventListener(PREVIEW_READY_EVENT, playOnce));
110
+ });
111
+ </script>
112
+
113
+ <template>
114
+ <div
115
+ ref="playerRef"
116
+ class="player"
117
+ :class="{ playing: media.isPlaying.value, fullscreen: isFullscreen }"
118
+ @dblclick="handlePlayerDoubleClick"
119
+ >
120
+ <!-- 背景动画 -->
121
+ <div class="bg-animation" />
122
+
123
+ <!-- 音频元素 -->
124
+ <audio
125
+ ref="audioRef"
126
+ :src="url"
127
+ preload="metadata"
128
+ @play="media.handlePlay"
129
+ @pause="media.handlePause"
130
+ @timeupdate="media.handleTimeUpdate"
131
+ @loadedmetadata="media.handleLoadedMetadata"
132
+ @canplay="media.handleCanPlay"
133
+ @waiting="media.handleWaiting"
134
+ @error="media.handleError"
135
+ />
136
+
137
+ <!-- 内容区域 -->
138
+ <div class="content">
139
+ <div class="cover">
140
+ <Icon icon="lucide:music-2" />
141
+ </div>
142
+ <p class="title">{{ name }}</p>
143
+ <p class="subtitle">音频文件</p>
144
+ </div>
145
+
146
+ <!-- 控制区域 -->
147
+ <div class="controls-wrapper">
148
+ <PlayerControls
149
+ :show-controls="true"
150
+ :has-error="media.hasError.value"
151
+ :is-playing="media.isPlaying.value"
152
+ :is-muted="media.isMuted.value"
153
+ :current-time="media.currentTime.value"
154
+ :duration="media.duration.value"
155
+ :played-percent="media.playedPercent.value"
156
+ :buffered-percent="media.bufferedPercent.value"
157
+ :volume="media.volume.value"
158
+ :playback-rate="media.playbackRate.value"
159
+ :is-fullscreen="isFullscreen"
160
+ :show-subtitle="false"
161
+ :show-pi-p="false"
162
+ :show-fullscreen="true"
163
+ :loop="media.loop.value"
164
+ @toggle-play="media.togglePlay"
165
+ @seek="media.seek"
166
+ @progress-change="handleProgressChange"
167
+ @volume-change="handleVolumeChange"
168
+ @toggle-mute="handleMuteToggle"
169
+ @speed-change="handleSpeedChange"
170
+ @toggle-fullscreen="toggleFullscreen"
171
+ @toggle-loop="handleLoopToggle"
172
+ />
173
+ </div>
174
+ </div>
175
+ </template>
176
+
177
+ <style scoped>
178
+ .player {
179
+ width: 100%;
180
+ min-width: 0;
181
+ height: 100%;
182
+ display: flex;
183
+ flex-direction: column;
184
+ background: #000;
185
+ position: relative;
186
+ overflow: hidden;
187
+ }
188
+
189
+ /* 背景动画 */
190
+ .bg-animation {
191
+ position: absolute;
192
+ inset: 0;
193
+ display: none;
194
+ }
195
+
196
+ @keyframes bgPulse {
197
+ 0%, 100% { transform: scale(1); opacity: 0.4; }
198
+ 50% { transform: scale(1.1); opacity: 0.6; }
199
+ }
200
+
201
+ .player.playing .bg-animation {
202
+ animation: bgPulseActive 2s ease-in-out infinite;
203
+ }
204
+
205
+ @keyframes bgPulseActive {
206
+ 0%, 100% { transform: scale(1); opacity: 0.5; }
207
+ 50% { transform: scale(1.15); opacity: 0.8; }
208
+ }
209
+
210
+ /* 内容区域 */
211
+ .content {
212
+ flex: 1;
213
+ display: flex;
214
+ flex-direction: column;
215
+ align-items: center;
216
+ justify-content: center;
217
+ /* 使用响应式 padding,确保最小间距,不会贴边 */
218
+ padding: clamp(20px, 5vw, 40px) clamp(24px, 6vw, 40px);
219
+ position: relative;
220
+ z-index: 1;
221
+ min-height: 0; /* 允许 flex 子元素缩小 */
222
+ }
223
+
224
+ /* 封面图标 */
225
+ .cover {
226
+ /* 响应式大小,根据窗口大小自适应 */
227
+ width: clamp(120px, min(25vw, 25vh), 180px);
228
+ height: clamp(120px, min(25vw, 25vh), 180px);
229
+ background: linear-gradient(
230
+ 135deg,
231
+ color-mix(in srgb, var(--huyooo-primary) 85%, #ec4899 15%) 0%,
232
+ color-mix(in srgb, var(--huyooo-primary) 65%, #8b5cf6 35%) 50%,
233
+ var(--huyooo-primary) 100%
234
+ );
235
+ border-radius: clamp(24px, 6vw, 36px);
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ box-shadow:
240
+ 0 25px 80px color-mix(in srgb, var(--huyooo-primary) 40%, transparent),
241
+ 0 10px 40px color-mix(in srgb, var(--huyooo-overlay) 50%, transparent);
242
+ margin-bottom: clamp(16px, 4vw, 32px);
243
+ transition: transform 0.3s, box-shadow 0.3s;
244
+ flex-shrink: 0; /* 防止被压缩 */
245
+ }
246
+
247
+ .player.playing .cover {
248
+ animation: coverPulse 2s ease-in-out infinite;
249
+ }
250
+
251
+ @keyframes coverPulse {
252
+ 0%, 100% { transform: scale(1); }
253
+ 50% { transform: scale(1.03); }
254
+ }
255
+
256
+ .cover :deep(.iconify) {
257
+ /* 响应式图标大小,相对于封面大小 */
258
+ width: 44%;
259
+ height: 44%;
260
+ max-width: 80px;
261
+ max-height: 80px;
262
+ font-size: 80px;
263
+ color: var(--huyooo-on-primary, #ffffff);
264
+ }
265
+
266
+ /* 文件名 */
267
+ .title {
268
+ color: var(--huyooo-text);
269
+ font-size: clamp(14px, 2.5vw, 18px);
270
+ font-weight: 600;
271
+ text-align: center;
272
+ /* 确保有足够的边距,不会贴边 */
273
+ max-width: min(90%, calc(100% - 48px));
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ margin-bottom: clamp(10px, 2.5vw, 16px);
278
+ padding: 0 clamp(12px, 3vw, 0);
279
+ }
280
+
281
+ .subtitle {
282
+ color: var(--huyooo-text-muted);
283
+ font-size: clamp(11px, 2vw, 13px);
284
+ padding: 0 clamp(12px, 3vw, 0);
285
+ }
286
+
287
+ /* 控制区域 */
288
+ .controls-wrapper {
289
+ width: 100%;
290
+ position: relative;
291
+ z-index: 1;
292
+ padding: 0 clamp(12px, 3vw, 16px) clamp(12px, 3vw, 16px);
293
+ flex-shrink: 0; /* 防止被压缩 */
294
+ }
295
+ </style>