diff --git a/components.d.ts b/components.d.ts index ca197163..d45e5aa3 100644 --- a/components.d.ts +++ b/components.d.ts @@ -108,6 +108,8 @@ declare module 'vue' { IconLucideMicVocal: typeof import('~icons/lucide/mic-vocal')['default'] IconLucideMinimize: typeof import('~icons/lucide/minimize')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] + IconLucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default'] + IconLucideMoreVertical: typeof import('~icons/lucide/more-vertical')['default'] IconLucideMusic: typeof import('~icons/lucide/music')['default'] IconLucidePause: typeof import('~icons/lucide/pause')['default'] IconLucidePlay: typeof import('~icons/lucide/play')['default'] @@ -135,15 +137,21 @@ declare module 'vue' { IconLucideVolumeX: typeof import('~icons/lucide/volume-x')['default'] IconLucideX: typeof import('~icons/lucide/x')['default'] IconLucideZap: typeof import('~icons/lucide/zap')['default'] + IconMaterialSymbolsCancelRounded: typeof import('~icons/material-symbols/cancel-rounded')['default'] + IconMaterialSymbolsChatBubbleRounded: typeof import('~icons/material-symbols/chat-bubble-rounded')['default'] + IconMaterialSymbolsCheckCircleRounded: typeof import('~icons/material-symbols/check-circle-rounded')['default'] IconMaterialSymbolsFavoriteOutlineRounded: typeof import('~icons/material-symbols/favorite-outline-rounded')['default'] + IconMaterialSymbolsInfoRounded: typeof import('~icons/material-symbols/info-rounded')['default'] IconMaterialSymbolsPlaylistPlayRounded: typeof import('~icons/material-symbols/playlist-play-rounded')['default'] IconMaterialSymbolsShuffleRounded: typeof import('~icons/material-symbols/shuffle-rounded')['default'] + IconMaterialSymbolsWarningRounded: typeof import('~icons/material-symbols/warning-rounded')['default'] IconSpHeartMode: typeof import('~icons/sp/heart-mode')['default'] IconSpLossless: typeof import('~icons/sp/lossless')['default'] IconSpPlayOrder: typeof import('~icons/sp/play-order')['default'] IconSpRepeatOff: typeof import('~icons/sp/repeat-off')['default'] LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default'] LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default'] + LyricActions: typeof import('./src/components/player/FullPlayer/LyricActions.vue')['default'] LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default'] Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default'] LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default'] @@ -169,6 +177,8 @@ declare module 'vue' { PopoverRoot: typeof import('reka-ui')['PopoverRoot'] PopoverTrigger: typeof import('reka-ui')['PopoverTrigger'] QualityControl: typeof import('./src/components/player/QualityControl.vue')['default'] + QueuePanel: typeof import('./src/components/player/FullPlayer/QueuePanel.vue')['default'] + QueuePopover: typeof import('./src/components/list/QueuePopover.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SAlert: typeof import('./src/components/ui/SAlert.vue')['default'] @@ -212,6 +222,7 @@ declare module 'vue' { SNumberInput: typeof import('./src/components/ui/SNumberInput.vue')['default'] SongList: typeof import('./src/components/list/SongList.vue')['default'] SpeedDialog: typeof import('./src/components/modals/SpeedDialog.vue')['default'] + SPerformanceMonitor: typeof import('./src/components/SPerformanceMonitor.vue')['default'] SPopover: typeof import('./src/components/ui/SPopover.vue')['default'] SPopselect: typeof import('./src/components/ui/SPopselect.vue')['default'] SRadio: typeof import('./src/components/ui/SRadio.vue')['default'] diff --git a/electron/main/store/utils.ts b/electron/main/store/utils.ts index 337fe212..ea814600 100644 --- a/electron/main/store/utils.ts +++ b/electron/main/store/utils.ts @@ -1,3 +1,5 @@ +export { getByPath, setByPath } from "@shared/utils/path"; + /** * 创建无原型对象,防止 JSON 原型污染 * @returns 无原型对象 @@ -8,8 +10,8 @@ export const createPlainObject = (): Record => /** * 深度合并 * defaults 为基底,stored 覆盖已有值,缺失字段从 defaults 补全 - * @param defaults 基底对象 - * @param stored 存储对象 + * @param defaults - 基底对象 + * @param stored - 存储对象 * @returns 合并后的对象 */ export const deepMerge = (defaults: T, stored: unknown): T => { @@ -32,35 +34,3 @@ export const deepMerge = (defaults: T, stored: unknown): T => { } return result as T; }; - -/** - * 通过 dot path 取值 - * @param obj 对象 - * @param dotPath 点号路径 - * @returns 值 - */ -export const getByPath = (obj: unknown, dotPath: string): unknown => { - const keys = dotPath.split("."); - let cur = obj as Record; - for (const k of keys) { - if (cur == null || typeof cur !== "object") return undefined; - cur = cur[k] as Record; - } - return cur; -}; - -/** - * 通过 dot path 赋值 - * @param obj 对象 - * @param dotPath 点号路径 - * @param value 值 - */ -export const setByPath = (obj: unknown, dotPath: string, value: unknown): void => { - const keys = dotPath.split("."); - let cur = obj as Record; - for (let i = 0; i < keys.length - 1; i++) { - if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") cur[keys[i]] = {}; - cur = cur[keys[i]] as Record; - } - cur[keys[keys.length - 1]] = value; -}; diff --git a/shared/utils/path.ts b/shared/utils/path.ts new file mode 100644 index 00000000..42e237ca --- /dev/null +++ b/shared/utils/path.ts @@ -0,0 +1,32 @@ +/** + * 通过 dot path 取嵌套值 + * @param obj - 对象 + * @param dotPath - 形如 "player.equalizer.bands" + * @returns 值;中间节点缺失返回 undefined + */ +export const getByPath = (obj: unknown, dotPath: string): unknown => { + const keys = dotPath.split("."); + let cur = obj as Record | null | undefined; + for (const key of keys) { + if (cur == null || typeof cur !== "object") return undefined; + cur = cur[key] as Record | null | undefined; + } + return cur; +}; + +/** + * 通过 dot path 写入嵌套值,缺失的中间对象会自动补齐 + * @param obj - 目标对象 + * @param dotPath - 形如 "player.equalizer.bands" + * @param value - 新值 + */ +export const setByPath = (obj: unknown, dotPath: string, value: unknown): void => { + const keys = dotPath.split("."); + let cur = obj as Record; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (cur[key] == null || typeof cur[key] !== "object") cur[key] = {}; + cur = cur[key] as Record; + } + cur[keys[keys.length - 1]] = value; +}; diff --git a/src/components/SPerformanceMonitor.vue b/src/components/SPerformanceMonitor.vue new file mode 100644 index 00000000..f4da999d --- /dev/null +++ b/src/components/SPerformanceMonitor.vue @@ -0,0 +1,200 @@ + + + diff --git a/src/components/layout/NavSearch.vue b/src/components/layout/NavSearch.vue index 705e0cd3..ab770f6c 100644 --- a/src/components/layout/NavSearch.vue +++ b/src/components/layout/NavSearch.vue @@ -5,6 +5,7 @@ import { getHotSearches, type HotSearchItem } from "@/apis/search/hot"; import { getSearchSuggest, type SuggestData } from "@/apis/search/suggest"; import { songsByIds as getNeteaseSongsByIds } from "@/apis/song/netease"; import { formatCompact } from "@/utils/format"; +import { navigateToAlbum, navigateToArtist, navigateToPlaylist } from "@/utils/navigate"; import * as player from "@/core/player"; const { t, locale } = useI18n(); @@ -59,15 +60,37 @@ const onPickKeyword = (keyword: string): void => submit(keyword); const onRemoveHistory = (keyword: string): void => data.removeSearchHistory(keyword); const onClearHistory = (): void => data.clearSearchHistory(); -/** 单曲建议点击 */ -const onPickSong = async (id: number): Promise => { +/** + * 建议点击 + * @param kind - 建议类型 + * @param id - 网易云 id + * @param name - 名称 + */ +const onPickSuggest = async ( + kind: "song" | "artist" | "album" | "playlist", + id: number, + name: string, +): Promise => { if (trimmedQuery.value) data.addSearchHistory(trimmedQuery.value); dialogOpen.value = false; - try { - const [track] = await getNeteaseSongsByIds([id]); - if (track) await player.playNow(track); - } catch (err) { - console.warn("[NavSearch] play suggest song failed:", err); + switch (kind) { + case "song": + try { + const [track] = await getNeteaseSongsByIds([id]); + if (track) await player.playNow(track); + } catch (err) { + console.warn("[NavSearch] play suggest song failed:", err); + } + break; + case "artist": + navigateToArtist(name, { source: "netease", artistId: String(id) }); + break; + case "album": + navigateToAlbum(name, { source: "netease", albumId: String(id) }); + break; + case "playlist": + navigateToPlaylist(String(id), { source: "netease", name }); + break; } }; @@ -174,7 +197,7 @@ onMounted(() => { v-for="song in suggest.songs" :key="song.id" class="min-w-0 flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-on-surface/5 transition-colors duration-200" - @click="onPickSong(song.id)" + @click="onPickSuggest('song', song.id, song.name)" >
{{ song.name }} @@ -194,7 +217,7 @@ onMounted(() => { v-for="artist in suggest.artists" :key="artist.id" class="min-w-0 flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-on-surface/5 transition-colors duration-200" - @click="onPickKeyword(artist.name)" + @click="onPickSuggest('artist', artist.id, artist.name)" > {{ artist.name }}
@@ -208,7 +231,7 @@ onMounted(() => { v-for="album in suggest.albums" :key="album.id" class="min-w-0 flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-on-surface/5 transition-colors duration-200" - @click="onPickKeyword(album.name)" + @click="onPickSuggest('album', album.id, album.name)" >
{{ album.name }} @@ -227,7 +250,7 @@ onMounted(() => { v-for="playlist in suggest.playlists" :key="playlist.id" class="min-w-0 flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-on-surface/5 transition-colors duration-200" - @click="onPickKeyword(playlist.name)" + @click="onPickSuggest('playlist', playlist.id, playlist.name)" > {{ playlist.name }}
diff --git a/src/components/list/PlaylistPanel.vue b/src/components/list/PlaylistPanel.vue deleted file mode 100644 index 42dd6c59..00000000 --- a/src/components/list/PlaylistPanel.vue +++ /dev/null @@ -1,229 +0,0 @@ - - - diff --git a/src/components/list/QueuePopover.vue b/src/components/list/QueuePopover.vue new file mode 100644 index 00000000..1ea77965 --- /dev/null +++ b/src/components/list/QueuePopover.vue @@ -0,0 +1,190 @@ + + + diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index 48e6ebd5..2994f6be 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -50,6 +50,8 @@ const props = withDefaults( collectionType?: CollectionType; /** 集合 ID */ collectionId?: string; + /** 是否有权从集合移除曲目 */ + canRemove?: boolean; /** 是否还能继续触底加载 */ hasMore?: boolean; /** 触底加载中 */ @@ -65,6 +67,7 @@ const props = withDefaults( source: "local", collectionType: undefined, collectionId: undefined, + canRemove: true, hasMore: false, loadingMore: false, }, @@ -216,6 +219,7 @@ const batch = useMultiSelect(sortedItems, { source: computed(() => props.source), collectionType: computed(() => props.collectionType), collectionId: computed(() => props.collectionId), + canRemove: computed(() => props.canRemove), onChanged: (removedIds) => emit("change", removedIds), }); const { deleteConfirmOpen, deleteDialogTitle, deleteDialogContent } = batch; @@ -238,6 +242,7 @@ const openPicker = (tracks: Track[]): void => { const contextTrack = shallowRef(); const { items: contextMenuItems, handleSelect: onContextMenu } = useTrackMenu(contextTrack, { collectionType: props.collectionType, + canRemove: props.canRemove, onAddToPlaylist: (track) => openPicker([track]), onRemove: (track) => batch.requestDelete([track], "remove"), onDeleteFile: (track) => batch.requestDelete([track], "file"), diff --git a/src/components/player/FullPlayer/LyricActions.vue b/src/components/player/FullPlayer/LyricActions.vue new file mode 100644 index 00000000..50fd1b05 --- /dev/null +++ b/src/components/player/FullPlayer/LyricActions.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/components/player/FullPlayer/PlayerBackground.vue b/src/components/player/FullPlayer/PlayerBackground.vue index 4f9db7ee..88d84f50 100644 --- a/src/components/player/FullPlayer/PlayerBackground.vue +++ b/src/components/player/FullPlayer/PlayerBackground.vue @@ -2,11 +2,13 @@ import { useSettingsStore } from "@/stores/settings"; import { useThemeStore } from "@/stores/theme"; import { useMediaStore } from "@/stores/media"; +import { useStatusStore } from "@/stores/status"; import DEFAULT_COVER from "@/assets/images/song.jpg"; const media = useMediaStore(); const settings = useSettingsStore(); const theme = useThemeStore(); +const status = useStatusStore(); const bgType = computed(() => settings.player.playerBgType); @@ -31,8 +33,9 @@ let preloadImg: HTMLImageElement | null = null; let switchToken = 0; watch( - () => media.track?.coverOriginal || media.track?.cover, - (newCover) => { + [() => media.track?.coverOriginal || media.track?.cover, () => status.isExpanded], + ([newCover, expanded]) => { + if (!expanded) return; const token = ++switchToken; if (preloadImg) { @@ -41,7 +44,9 @@ watch( preloadImg.src = ""; preloadImg = null; } - + const targetCover = newCover || DEFAULT_COVER; + // 相同不切换 + if (blurLayers[currentLayerIndex].src === targetCover) return; const nextIndex = currentLayerIndex === 0 ? 1 : 0; const switchLayer = (src: string) => { if (token !== switchToken) return; @@ -57,9 +62,6 @@ watch( }); }); }; - - const targetCover = newCover || DEFAULT_COVER; - const img = new Image(); preloadImg = img; img.onload = () => switchLayer(targetCover); diff --git a/src/components/player/FullPlayer/PlayerCover.vue b/src/components/player/FullPlayer/PlayerCover.vue index f8f89a2c..a06991d6 100644 --- a/src/components/player/FullPlayer/PlayerCover.vue +++ b/src/components/player/FullPlayer/PlayerCover.vue @@ -2,52 +2,63 @@ import { useMediaStore } from "@/stores/media"; import { useStatusStore } from "@/stores/status"; -const media = useMediaStore(); -const { isPlaying } = storeToRefs(useStatusStore()); +withDefaults(defineProps<{ fullscreen?: boolean }>(), { fullscreen: false }); -// 高清封面 data URL -const hdCover = ref(null); -const coverSrc = computed(() => hdCover.value || media.track?.coverOriginal || media.track?.cover); +const media = useMediaStore(); +const status = useStatusStore(); +const { isPlaying } = storeToRefs(status); -// 歌曲切换时获取高清封面,拿到前保持旧封面 -watch( - () => media.track?.id, - async (newId) => { - hdCover.value = null; - if (!newId) return; +/** 高清封面缓存 */ +const hdCache = shallowRef<{ id: string; data: string } | null>(null); - try { - const result = await window.api.player.getCoverRaw(); - if (media.track?.id !== newId) return; - hdCover.value = result.success ? (result.data ?? null) : null; - } catch { - if (media.track?.id === newId) hdCover.value = null; - } - }, - { immediate: true }, +const coverSrc = computed(() => + hdCache.value && hdCache.value.id === media.track?.id + ? hdCache.value.data + : media.track?.coverOriginal || media.track?.cover, ); + +watchEffect(async () => { + const id = media.track?.id; + if (!status.isExpanded || status.trackLoading || !id) return; + if (media.track?.source !== "local" || hdCache.value?.id === id) return; + const r = await window.api.player.getCoverRaw(); + if (media.track?.id !== id || !r.success || !r.data) return; + hdCache.value = { id, data: r.data }; +}); diff --git a/src/components/player/FullPlayer/PlayerData.vue b/src/components/player/FullPlayer/PlayerData.vue index 209749ea..a0f7aecb 100644 --- a/src/components/player/FullPlayer/PlayerData.vue +++ b/src/components/player/FullPlayer/PlayerData.vue @@ -13,7 +13,7 @@ const props = withDefaults( defineProps<{ /** 对齐方式 */ align?: "center" | "left" | "right"; - /** 简单模式:隐藏标签行和副标题 */ + /** 简单模式 */ simple?: boolean; }>(), { @@ -131,7 +131,7 @@ const alignItems = computed(() => { {{ media.track.comment }} -
+
diff --git a/src/components/player/FullPlayer/QueuePanel.vue b/src/components/player/FullPlayer/QueuePanel.vue new file mode 100644 index 00000000..65d54227 --- /dev/null +++ b/src/components/player/FullPlayer/QueuePanel.vue @@ -0,0 +1,143 @@ + + + diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 3e495581..e2853476 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -1,22 +1,24 @@ + + +
@@ -503,6 +498,7 @@ const resetLyricOffset = (): void => writeOffset(0);
+ @@ -519,4 +515,33 @@ const resetLyricOffset = (): void => writeOffset(0); hsla(0, 0%, 100%, 0) ); } + +/* 顶部/底部遮罩:多段非线性 alpha,避免暗色渐变出色阶 */ +.cover-mask-top { + background-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.5) 0%, + rgba(0, 0, 0, 0.44) 12%, + rgba(0, 0, 0, 0.36) 25%, + rgba(0, 0, 0, 0.27) 40%, + rgba(0, 0, 0, 0.18) 55%, + rgba(0, 0, 0, 0.1) 70%, + rgba(0, 0, 0, 0.04) 85%, + rgba(0, 0, 0, 0) 100% + ); +} + +.cover-mask-bottom { + background-image: linear-gradient( + to top, + rgba(0, 0, 0, 0.5) 0%, + rgba(0, 0, 0, 0.44) 12%, + rgba(0, 0, 0, 0.36) 25%, + rgba(0, 0, 0, 0.27) 40%, + rgba(0, 0, 0, 0.18) 55%, + rgba(0, 0, 0, 0.1) 70%, + rgba(0, 0, 0, 0.04) 85%, + rgba(0, 0, 0, 0) 100% + ); +} diff --git a/src/components/player/Lyrics/engine/index.ts b/src/components/player/Lyrics/engine/index.ts index cfbfabb0..9af1630c 100644 --- a/src/components/player/Lyrics/engine/index.ts +++ b/src/components/player/Lyrics/engine/index.ts @@ -226,6 +226,9 @@ export class LyricRenderer { freeze = () => { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = 0; + // 断开 observer + this.containerResizeObserver.disconnect(); + this.sentinelResizeObserver.disconnect(); // Web Animations 走自己的时间线,不随 rAF 暂停而停止;冻结期间也要 pause for (const anims of this.activeAnimations.values()) { for (const anim of anims) anim.pause(); @@ -235,6 +238,10 @@ export class LyricRenderer { /** 恢复渲染 */ resume = () => { if (this.animationFrameId !== 0) return; + this.containerResizeObserver.observe(this.container); + if (this.sentinelElement) { + this.sentinelResizeObserver.observe(this.sentinelElement); + } this.lastFrameTimestamp = 0; this.needsFullSync = true; // 恢复时按当前播放时间重新对齐动画 currentTime 后再 play @@ -280,9 +287,9 @@ export class LyricRenderer { * @param lines - 歌词行数组 */ setLyrics = (lines: LyricLine[]) => { + const seekTime = this.pendingPlayTime >= 0 ? this.pendingPlayTime : 0; this.cancelAllActiveAnimations(); for (const element of this.lineElements) element.remove(); - // 重置状态 this.lines = lines; this.activeLineIndex = -1; @@ -410,8 +417,8 @@ export class LyricRenderer { // 重置帧时间戳,避免渲染器空闲后首帧 deltaTime 过大导致弹簧瞬移 this.lastFrameTimestamp = 0; - // 初始布局 + 入场动画(从 time=0 开始) - this.handleSeek(0); + // 初始布局 + 入场动画 + this.handleSeek(seekTime); this.calculateLayout(true); this.playEntranceAnimation(this.containerHeight * 0.6); this.needsFullSync = true; diff --git a/src/components/player/Lyrics/index.vue b/src/components/player/Lyrics/index.vue index b38107f7..d8375855 100644 --- a/src/components/player/Lyrics/index.vue +++ b/src/components/player/Lyrics/index.vue @@ -90,6 +90,8 @@ const props = withDefaults( showTranslation?: boolean; /** 是否显示音译歌词 @default true */ showRomanization?: boolean; + /** 挂载时的初始播放时间(毫秒)@default 0 */ + initialTime?: number; }>(), { playing: false, @@ -108,6 +110,7 @@ const props = withDefaults( enableEmphasizeEffect: DEFAULTS.enableEmphasizeEffect, showTranslation: true, showRomanization: true, + initialTime: 0, }, ); @@ -158,12 +161,15 @@ const handleLineClick = (timeMs: number) => { onMounted(() => { if (!containerRef.value) return; - const { lyricLines: _lyricLines, ...config } = props; + const { lyricLines: _lyricLines, initialTime: _initialTime, ...config } = props; renderer = new LyricRenderer(containerRef.value, { ...config, springConfig: props.springConfig ?? {}, onLineClick: handleLineClick, }); + if (props.initialTime > 0) { + renderer.setCurrentTime(props.initialTime); + } if (props.lyricLines.length > 0) { renderer.setLyrics(props.lyricLines); } diff --git a/src/components/player/PlayerBar.vue b/src/components/player/PlayerBar.vue index 5450ba25..61f14a0a 100644 --- a/src/components/player/PlayerBar.vue +++ b/src/components/player/PlayerBar.vue @@ -1,12 +1,16 @@ diff --git a/src/components/player/QualityControl.vue b/src/components/player/QualityControl.vue index 390e0fae..263821e2 100644 --- a/src/components/player/QualityControl.vue +++ b/src/components/player/QualityControl.vue @@ -13,7 +13,7 @@ const media = useMediaStore(); const settings = useSettingsStore(); /** 是否支持切换在线音质 */ -const isNetease = computed(() => media.track?.source === "netease"); +const canSwitchQuality = computed(() => media.track?.source === "netease" && !media.track?.cloud); /** 实际播放音质 */ const qualityLabel = computed(() => getQualityLabel(media.detail?.quality)); @@ -39,7 +39,7 @@ const onQualityChange = (value: string | number | boolean): void => { - - {{ qualityLabel }} - + + {{ qualityLabel }} + + diff --git a/src/components/player/Toolbar.vue b/src/components/player/Toolbar.vue index 936ba7c3..242515c2 100644 --- a/src/components/player/Toolbar.vue +++ b/src/components/player/Toolbar.vue @@ -123,16 +123,31 @@ const onMoreMenuSelect = (key: string): void => { + + + + { />
- +
-
+
{ diff --git a/src/components/ui/SContextMenu.vue b/src/components/ui/SContextMenu.vue index da43bbe9..72fd0d38 100644 --- a/src/components/ui/SContextMenu.vue +++ b/src/components/ui/SContextMenu.vue @@ -46,6 +46,7 @@ const menuItemClass = @@ -63,6 +64,7 @@ const menuItemClass =