starmusic-pc
1<script setup>
2/**
3 * App — 应用外壳
4 *
5 * 职责:
6 * 1. 启动初始化(数据列表 / 自定义源 / 播放器 / 外观 / 彩蛋配置)
7 * 2. 整体布局:标题栏 + 侧边栏 + 主视图 + 播放队列 + 播放条
8 * 3. 主题与背景图变量注入(.app-root 的内联样式)
9 * 4. 全局键盘快捷键(空格播放暂停、方向键切歌/调音量、Esc 关闭浮层等)
10 * 5. 用 uni 路由桥把移植代码里的 uni.navigateTo 映射到桌面视图
11 */
12import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
13
14import AppTitleBar from './components/AppTitleBar.vue'
15import AppSideBar from './components/AppSideBar.vue'
16import PlayerBar from './components/PlayerBar.vue'
17import PlayQueue from './components/PlayQueue.vue'
18import MiniPlayer from './components/MiniPlayer.vue'
19import UiFeedback from './components/UiFeedback.vue'
20
21import SplashView from './views/SplashView.vue'
22import SearchView from './views/SearchView.vue'
23import PlaylistView from './views/PlaylistView.vue'
24import SettingsView from './views/SettingsView.vue'
25import AccountView from './views/AccountView.vue'
26import AboutView from './views/AboutView.vue'
27import PlayDetailView from './views/PlayDetailView.vue'
28
29import { initList } from './common/playlist.js'
30import { initUserApi } from './common/user-api/api-executor.js'
31import { initApiSource } from './common/api-source.js'
32import { initAutoBackup } from './common/account.js'
33import { fetchEasterEggs } from './common/easter-egg.js'
34import { checkUpdate, promptUpdate } from './common/update.js'
35
36import { initPlayerStore, playerState, playerActions } from './composables/usePlayer.js'
37import { initAppearance, appearance } from './composables/useTheme.js'
38import {
39 currentView,
40 nowPlayingOpen,
41 closeNowPlaying,
42 queueOpen,
43 toggleQueue,
44 sidebarCollapsed,
45 miniMode,
46 splashVisible,
47 registerUniRouterBridge,
48 go,
49} from './composables/useShell.js'
50import { toast } from './composables/useFeedback.js'
51import { toggleFullscreen, setWindowTitle } from './uni/native.js'
52
53const VIEW_MAP = {
54 search: SearchView,
55 playlists: PlaylistView,
56 settings: SettingsView,
57 account: AccountView,
58 about: AboutView,
59}
60
61const currentComponent = shallowRef(SearchView)
62
63/** 预加载过的视图组件缓存,切换时零等待 */
64const loaded = new Map([['search', SearchView]])
65
66const resolveView = (key) => {
67 if (!loaded.has(key)) loaded.set(key, VIEW_MAP[key] || SearchView)
68 return loaded.get(key)
69}
70
71const viewKey = computed(() => currentView.value)
72
73// ============ 启动初始化 ============
74
75const bootError = ref('')
76
77const boot = () => {
78 try {
79 initList()
80 initUserApi()
81 initApiSource()
82 initPlayerStore()
83 initAppearance()
84 registerUniRouterBridge()
85 // 自动备份:开启且已登录时,歌单/音源变化后自动上传云端
86 initAutoBackup()
87 // 彩蛋配置静默拉取(失败自动回退上次缓存)
88 fetchEasterEggs()
89 } catch (err) {
90 bootError.value = (err && err.message) || String(err)
91 console.error('[boot]', err)
92 }
93}
94
95boot()
96
97onMounted(() => {
98 currentComponent.value = resolveView(currentView.value)
99 setWindowTitle('星音乐 star-music')
100
101 // 启动 3 秒后静默检查更新,失败不影响使用
102 setTimeout(() => {
103 checkUpdate()
104 .then((info) => {
105 if (info) promptUpdate(info)
106 })
107 .catch(() => {})
108 }, 3000)
109
110 // 窗口宽度自适应折叠侧栏(用户手动折叠过就不再自动干预)
111 syncSidebarByWidth()
112 window.addEventListener('resize', syncSidebarByWidth)
113 window.addEventListener('keydown', onKeydown, true)
114})
115
116onBeforeUnmount(() => {
117 window.removeEventListener('resize', syncSidebarByWidth)
118 window.removeEventListener('keydown', onKeydown, true)
119})
120
121let sidebarTouched = false
122const syncSidebarByWidth = () => {
123 const w = window.innerWidth
124 if (w < 1080) sidebarCollapsed.value = true
125 else if (!sidebarTouched && w > 1240) sidebarCollapsed.value = false
126}
127
128// 视图切换时更新渲染组件
129import { watch } from 'vue'
130watch(currentView, (key) => {
131 currentComponent.value = resolveView(key)
132})
133
134// 监听用户手动折叠(用于停止自动展开)
135watch(sidebarCollapsed, () => {
136 sidebarTouched = true
137})
138
139// ============ 全局快捷键 ============
140
141const isTyping = (el) => {
142 if (!el) return false
143 const tag = el.tagName
144 return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
145}
146
147const onKeydown = (e) => {
148 if (isTyping(e.target)) return
149
150 const ctrl = e.ctrlKey || e.metaKey
151
152 switch (e.key) {
153 case ' ':
154 e.preventDefault()
155 playerActions.togglePlay()
156 break
157 case 'ArrowRight':
158 if (ctrl) {
159 e.preventDefault()
160 playerActions.playNext(false)
161 } else {
162 e.preventDefault()
163 playerActions.seek(Math.min(playerState.duration || 0, playerState.currentTime + 5))
164 }
165 break
166 case 'ArrowLeft':
167 if (ctrl) {
168 e.preventDefault()
169 playerActions.playPrev()
170 } else {
171 e.preventDefault()
172 playerActions.seek(Math.max(0, playerState.currentTime - 5))
173 }
174 break
175 case 'ArrowUp':
176 e.preventDefault()
177 playerActions.setVolume(Math.min(1, playerState.volume + 0.05))
178 break
179 case 'ArrowDown':
180 e.preventDefault()
181 playerActions.setVolume(Math.max(0, playerState.volume - 0.05))
182 break
183 case 'Escape':
184 if (nowPlayingOpen.value) {
185 e.preventDefault()
186 closeNowPlaying()
187 } else if (queueOpen.value) {
188 e.preventDefault()
189 toggleQueue(false)
190 }
191 break
192 case 'q':
193 case 'Q':
194 if (!ctrl) {
195 e.preventDefault()
196 toggleQueue()
197 }
198 break
199 case 'f':
200 case 'F':
201 if (ctrl) {
202 e.preventDefault()
203 toggleFullscreen()
204 }
205 break
206 case '1':
207 go('search')
208 break
209 case '2':
210 go('playlists')
211 break
212 case '3':
213 go('settings')
214 break
215 default:
216 break
217 }
218}
219
220// ============ 视图过渡方向 ============
221
222const transitionName = computed(() => 'view')
223</script>
224
225<template>
226 <!-- 迷你模式:只渲染紧凑播放器 -->
227 <div
228 v-if="miniMode"
229 class="app-root mini-root"
230 :style="appearance.style"
231 :class="{ 'bg-on': appearance.bgOn }"
232 >
233 <MiniPlayer />
234 <UiFeedback />
235 </div>
236
237 <!-- 完整界面 -->
238 <div
239 v-else
240 class="app-root"
241 :style="appearance.style"
242 :class="{ 'bg-on': appearance.bgOn }"
243 >
244 <!-- 自定义背景图层 -->
245 <div v-if="appearance.bgStyle" class="app-bg" :style="appearance.bgStyle" />
246
247 <AppTitleBar />
248
249 <div class="app-body">
250 <AppSideBar />
251
252 <main class="app-main">
253 <p v-if="bootError" class="boot-error">
254 初始化出现异常:{{ bootError }}
255 </p>
256 <Transition :name="transitionName" mode="out-in">
257 <component :is="currentComponent" :key="viewKey" class="app-view" />
258 </Transition>
259 </main>
260
261 <Transition name="queue">
262 <PlayQueue v-if="queueOpen" />
263 </Transition>
264 </div>
265
266 <PlayerBar />
267
268 <!-- 播放详情覆盖层 -->
269 <Transition name="overlay">
270 <PlayDetailView v-if="nowPlayingOpen" @close="closeNowPlaying" />
271 </Transition>
272
273 <!-- 启动页 -->
274 <Transition name="overlay">
275 <SplashView v-if="splashVisible" />
276 </Transition>
277
278 <UiFeedback />
279 </div>
280</template>
281
282<style scoped>
283.app-root {
284 position: relative;
285 height: 100vh;
286 display: flex;
287 flex-direction: column;
288 overflow: hidden;
289 background: var(--surface-0);
290 color: var(--text-1);
291 isolation: isolate;
292}
293
294/* 背景图层:绘制在所有内容下方,毛玻璃卡片会透出它 */
295.app-bg {
296 position: absolute;
297 inset: 0;
298 z-index: 0;
299 background-size: cover;
300 background-position: center;
301 background-repeat: no-repeat;
302 /* 轻微放大,避免 blur 后边缘露出底色 */
303 transform: scale(1.08);
304 pointer-events: none;
305}
306
307.app-body {
308 flex: 1;
309 min-height: 0;
310 display: flex;
311 position: relative;
312 z-index: 10;
313}
314
315.app-main {
316 flex: 1;
317 min-width: 0;
318 min-height: 0;
319 position: relative;
320 background: transparent;
321}
322
323.app-view {
324 height: 100%;
325}
326
327.boot-error {
328 margin: 12px 16px 0;
329 padding: 10px 14px;
330 border-radius: var(--r-md);
331 background: rgba(244, 63, 94, 0.1);
332 color: var(--color-danger);
333 font-size: 12.5px;
334}
335
336/* 视图过渡:按切换方向滑入 */
337.view-enter-active {
338 animation: kSlideInRight var(--t-slow) var(--ease-out);
339}
340
341.view-leave-active {
342 animation: kFadeIn var(--t-fast) reverse both;
343 position: absolute;
344 inset: 0;
345 pointer-events: none;
346}
347
348.overlay-enter-active,
349.overlay-leave-active {
350 transition: opacity var(--t-slow) var(--ease-out);
351}
352
353.overlay-enter-from,
354.overlay-leave-to {
355 opacity: 0;
356}
357
358.queue-enter-active,
359.queue-leave-active {
360 transition: transform var(--t-slow) var(--ease-out), opacity var(--t-base) var(--ease-out);
361}
362
363.queue-enter-from,
364.queue-leave-to {
365 transform: translateX(24px);
366 opacity: 0;
367}
368</style>