From 7bcedf8aad6dd23c8415de18100b683343e8a5d0 Mon Sep 17 00:00:00 2001 From: Shomi <96868062+Shomi-FJS@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:16:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(lyric/setting):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E6=AD=8C=E8=AF=8D=E6=97=B6=E9=97=B4=E8=BD=B4?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增全局歌词偏移相关的状态配置到设置存储 - 在设置页面添加全局歌词偏移的完整配置选项 - 在播放器快捷菜单中集成全局歌词偏移的快速调节控件 - 为桌面端与移动端播放器封面添加双击应用全局偏移的交互 - 优化移动端歌词页封面的手势交互避免冲突 --- src/components/Player/FullPlayerMobile.vue | 90 +++++++++++++- src/components/Player/PlayerLyric/index.vue | 9 +- .../Player/PlayerMeta/PlayerCover.vue | 61 ++++++++++ .../Player/PlayerQuickActionsMenu.vue | 111 ++++++++++++++++++ src/components/Setting/config/lyric.ts | 60 ++++++++++ src/stores/setting.ts | 15 +++ src/stores/status.ts | 19 ++- 7 files changed, 356 insertions(+), 9 deletions(-) diff --git a/src/components/Player/FullPlayerMobile.vue b/src/components/Player/FullPlayerMobile.vue index 7303bfe56..727291ca9 100644 --- a/src/components/Player/FullPlayerMobile.vue +++ b/src/components/Player/FullPlayerMobile.vue @@ -167,7 +167,18 @@ let savedPageType: MobilePageType = "info";
- +
+ +
{{ @@ -301,6 +312,66 @@ const pageIndex = ref(resolveSavedPageIndex(savedPageType)); const pageTransitionDisabled = ref(false); const pageSwipeBlocked = ref(false); let pageTransitionTimer = 0; +let lastLyricCoverTapAt = 0; +let lastLyricCoverTapX = 0; +let lastLyricCoverTapY = 0; + +const LYRIC_COVER_DOUBLE_TAP_DELAY = 320; +const LYRIC_COVER_DOUBLE_TAP_DISTANCE = 24; + +const applyGlobalLyricOffsetToCurrentSong = () => { + if (!settingStore.globalLyricOffsetEnabled || !settingStore.globalLyricOffsetDoubleClickApply) { + return; + } + const currentSongId = musicStore.playSong?.id; + if (!currentSongId) return; + + const offsetValue = settingStore.globalLyricOffsetValue; + const currentOffset = statusStore.getSongOffset(currentSongId); + const sign = offsetValue > 0 ? "+" : ""; + + if (settingStore.globalLyricOffsetAlwaysApply) { + if (currentOffset === offsetValue) { + statusStore.setSongOffset(currentSongId, -offsetValue); + window.$message?.success("本歌曲将临时关闭偏移"); + } else { + statusStore.resetSongOffset(currentSongId); + window.$message?.success(`已恢复全局偏移: ${sign}${offsetValue}ms`); + } + } else { + if (currentOffset === offsetValue) { + statusStore.resetSongOffset(currentSongId); + window.$message?.success(`已关闭单曲偏移: ${sign}${offsetValue}ms`); + } else { + statusStore.setSongOffset(currentSongId, offsetValue); + window.$message?.success(`已开启单曲偏移: ${sign}${offsetValue}ms`); + } + } +}; + +// 歌词页封面使用双点,避免和移动端下滑手势冲突 +const onLyricCoverPointerUp = (event: PointerEvent) => { + event.stopPropagation(); + if (event.cancelable) event.preventDefault(); + if (event.pointerType === "mouse" && event.button !== 0) return; + + const now = Date.now(); + const dx = event.clientX - lastLyricCoverTapX; + const dy = event.clientY - lastLyricCoverTapY; + const isDoubleTap = + now - lastLyricCoverTapAt <= LYRIC_COVER_DOUBLE_TAP_DELAY && + Math.hypot(dx, dy) <= LYRIC_COVER_DOUBLE_TAP_DISTANCE; + + if (isDoubleTap) { + lastLyricCoverTapAt = 0; + applyGlobalLyricOffsetToCurrentSong(); + return; + } + + lastLyricCoverTapAt = now; + lastLyricCoverTapX = event.clientX; + lastLyricCoverTapY = event.clientY; +}; const lyricHeaderHorizontalPadding = computed(() => { const padding = Math.max(0, settingStore.lyricHorizontalOffset); @@ -337,7 +408,8 @@ const dragHandleStyle = computed(() => { if (currentPageType.value === "lyric") { return { top: "calc(52px + var(--mobile-safe-top))", - left: "16px", + // 歌词页保留下滑手势,但避开左侧封面区域,防止封面双点被捕获层吃掉 + left: "calc(20px + var(--lyric-h-offset, 0px) + 72px)", right: "72px", height: "74px", }; @@ -926,11 +998,20 @@ const contentTransform = computed(() => { } .lyric-cover { + position: relative; + z-index: 11; width: 50px; height: 50px; flex-shrink: 0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; + touch-action: manipulation; + + .lyric-cover-image { + width: 100%; + height: 100%; + } :deep(img) { width: 100%; @@ -1239,6 +1320,11 @@ const contentTransform = computed(() => { height: 64px; border-radius: 10px; + .lyric-cover-image { + width: 100%; + height: 100%; + } + :deep(img) { border-radius: 10px; } diff --git a/src/components/Player/PlayerLyric/index.vue b/src/components/Player/PlayerLyric/index.vue index cc2a01193..7737e84df 100644 --- a/src/components/Player/PlayerLyric/index.vue +++ b/src/components/Player/PlayerLyric/index.vue @@ -163,7 +163,12 @@ const offsetMilliseconds = computed({ return statusStore.getSongOffset(currentSongId.value); }, set: (val: number | null) => { - statusStore.setSongOffset(currentSongId.value, val || 0); + const settingStore = useSettingStore(); + const globalOffset = (settingStore.globalLyricOffsetEnabled && settingStore.globalLyricOffsetAlwaysApply) + ? settingStore.globalLyricOffsetValue + : 0; + const localOffset = (val || 0) - globalOffset; + statusStore.setSongOffset(currentSongId.value, localOffset); }, }); @@ -179,7 +184,7 @@ const changeOffset = (delta: number) => { * 重置进度偏移 */ const resetOffset = () => { - statusStore.resetSongOffset(currentSongId.value); + offsetMilliseconds.value = 0; }; onMounted(() => { diff --git a/src/components/Player/PlayerMeta/PlayerCover.vue b/src/components/Player/PlayerMeta/PlayerCover.vue index 3be0442a8..98018473b 100644 --- a/src/components/Player/PlayerMeta/PlayerCover.vue +++ b/src/components/Player/PlayerMeta/PlayerCover.vue @@ -4,6 +4,8 @@ v-if="settingStore.playerType === 'fullscreen' && !isPhone" class="full-screen" :style="{ '--gradient-percent': settingStore.playerFullscreenGradient + '%' }" + @pointerdown.stop + @pointerup="onCoverPointerUp" > { return musicStore.getSongCover(size); }; +// 双击封面切换当前歌曲的偏移状态 +const onDoubleClickCover = () => { + if (settingStore.globalLyricOffsetEnabled && settingStore.globalLyricOffsetDoubleClickApply) { + const currentSongId = musicStore.playSong?.id; + if (!currentSongId) return; + + const offsetValue = settingStore.globalLyricOffsetValue; + const currentOffset = statusStore.getSongOffset(currentSongId); + const sign = offsetValue > 0 ? "+" : ""; + + if (settingStore.globalLyricOffsetAlwaysApply) { + if (currentOffset === offsetValue) { + statusStore.setSongOffset(currentSongId, -offsetValue); + window.$message?.success("本歌曲将临时关闭偏移"); + } else { + statusStore.resetSongOffset(currentSongId); + window.$message?.success(`已恢复全局偏移: ${sign}${offsetValue}ms`); + } + } else { + if (currentOffset === offsetValue) { + statusStore.resetSongOffset(currentSongId); + window.$message?.success(`已关闭单曲偏移: ${sign}${offsetValue}ms`); + } else { + statusStore.setSongOffset(currentSongId, offsetValue); + window.$message?.success(`已开启单曲偏移: ${sign}${offsetValue}ms`); + } + } + } +}; + +let lastCoverTapAt = 0; +let lastCoverTapX = 0; +let lastCoverTapY = 0; +const COVER_DOUBLE_TAP_DELAY = 320; +const COVER_DOUBLE_TAP_DISTANCE = 24; + +const onCoverPointerUp = (event: PointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + + const now = Date.now(); + const dx = event.clientX - lastCoverTapX; + const dy = event.clientY - lastCoverTapY; + const isDoubleTap = + now - lastCoverTapAt <= COVER_DOUBLE_TAP_DELAY && + Math.hypot(dx, dy) <= COVER_DOUBLE_TAP_DISTANCE; + + if (isDoubleTap) { + lastCoverTapAt = 0; + onDoubleClickCover(); + return; + } + + lastCoverTapAt = now; + lastCoverTapX = event.clientX; + lastCoverTapY = event.clientY; +}; + watch( () => [musicStore.playSong.id, settingStore.dynamicCover, settingStore.playerType], () => getDynamicCover(), diff --git a/src/components/Player/PlayerQuickActionsMenu.vue b/src/components/Player/PlayerQuickActionsMenu.vue index dbaa83fd1..c2db52461 100644 --- a/src/components/Player/PlayerQuickActionsMenu.vue +++ b/src/components/Player/PlayerQuickActionsMenu.vue @@ -250,6 +250,104 @@
+ + +
+
+ + 全局偏移 +
+ +
+ +
+
+ + 偏移数值 + {{ settingStore.globalLyricOffsetValue > 0 ? '+' : '' }}{{ settingStore.globalLyricOffsetValue }}ms +
+
+ + - + + + + + +
+
+ +
+
+ + 0ms + + + {{ settingStore.globalLyricOffsetPresetSign === '-' ? '-250ms' : '+250ms' }} + + + {{ settingStore.globalLyricOffsetPresetSign === '-' ? '-500ms' : '+500ms' }} + + + {{ settingStore.globalLyricOffsetPresetSign === '-' ? '-850ms' : '+850ms' }} + +
+
+ +
+
+ + 快捷预设单位 + {{ settingStore.globalLyricOffsetPresetSign }} +
+
+ + + + + + - + +
+
+ +
+
+ + 始终启用偏移 +
+ +
+ +
+
+ + 双击封面开关 +
+ +
@@ -326,6 +424,19 @@ const adjustLandscapeCoverOffset = (delta: number) => { ); }; +const adjustGlobalLyricOffset = (delta: number) => { + settingStore.globalLyricOffsetValue = clampValue( + settingStore.globalLyricOffsetValue + delta, + -10000, + 10000, + ); +}; + +const applyGlobalLyricOffsetPreset = (value: number) => { + const signMultiplier = settingStore.globalLyricOffsetPresetSign === "-" ? -1 : 1; + settingStore.globalLyricOffsetValue = value * signMultiplier; +}; + const adjustLandscapeLyricPadding = (delta: number) => { settingStore.landscapeLyricPaddingX = clampValue( settingStore.landscapeLyricPaddingX + delta, diff --git a/src/components/Setting/config/lyric.ts b/src/components/Setting/config/lyric.ts index fb5912c28..f1d85c9de 100644 --- a/src/components/Setting/config/lyric.ts +++ b/src/components/Setting/config/lyric.ts @@ -462,6 +462,66 @@ export const useLyricSettings = (): SettingConfig => { set: (v) => (settingStore.lyricsBlendMode = v), }), }, + { + key: "globalLyricOffsetEnabled", + label: "全局歌词偏移", + type: "switch", + description: "是否启用全局歌词时间轴偏移", + value: computed({ + get: () => settingStore.globalLyricOffsetEnabled, + set: (v) => (settingStore.globalLyricOffsetEnabled = v), + }), + children: [ + { + key: "globalLyricOffsetValue", + label: "偏移数值", + type: "input-number", + description: "全局歌词偏移数值 (+ -),单位毫秒", + min: -10000, + max: 10000, + step: 10, + suffix: "ms", + value: computed({ + get: () => settingStore.globalLyricOffsetValue, + set: (v) => (settingStore.globalLyricOffsetValue = v || 0), + }), + }, + { + key: "globalLyricOffsetPresetSign", + label: "快捷预设单位", + type: "select", + description: "快捷开关中的预设数值符号 (+ 或 -)", + options: [ + { label: "+", value: "+" }, + { label: "-", value: "-" }, + ], + value: computed({ + get: () => settingStore.globalLyricOffsetPresetSign, + set: (v) => (settingStore.globalLyricOffsetPresetSign = v), + }), + }, + { + key: "globalLyricOffsetAlwaysApply", + label: "始终启用偏移", + type: "switch", + description: "开启后,任何歌曲播放时都将默认应用该偏移数值", + value: computed({ + get: () => settingStore.globalLyricOffsetAlwaysApply, + set: (v) => (settingStore.globalLyricOffsetAlwaysApply = v), + }), + }, + { + key: "globalLyricOffsetDoubleClickApply", + label: "双击歌词页封面应用偏移", + type: "switch", + description: "开启后,双击歌词页封面可应用当前全局偏移", + value: computed({ + get: () => settingStore.globalLyricOffsetDoubleClickApply, + set: (v) => (settingStore.globalLyricOffsetDoubleClickApply = v), + }), + }, + ], + }, { key: "lyricOffsetStep", label: "歌词时延调节步长", diff --git a/src/stores/setting.ts b/src/stores/setting.ts index 0adb3d7d3..df9e623e2 100644 --- a/src/stores/setting.ts +++ b/src/stores/setting.ts @@ -425,6 +425,16 @@ export interface SettingState { time: boolean; description: boolean; }; + /** 全局歌词时间轴偏移开关 */ + globalLyricOffsetEnabled: boolean; + /** 全局歌词时间轴偏移数值 (ms) */ + globalLyricOffsetValue: number; + /** 全局歌词时间轴快捷预设单位符号 */ + globalLyricOffsetPresetSign: "+" | "-"; + /** 双击歌词页封面应用全局偏移 */ + globalLyricOffsetDoubleClickApply: boolean; + /** 始终启用全局偏移 */ + globalLyricOffsetAlwaysApply: boolean; /** 全屏播放器界面元素显示配置 */ fullscreenPlayerElements: { like: boolean; @@ -758,6 +768,11 @@ export const useSettingStore = defineStore("setting", { time: true, description: true, }, + globalLyricOffsetEnabled: false, + globalLyricOffsetValue: 0, + globalLyricOffsetPresetSign: "+", + globalLyricOffsetDoubleClickApply: false, + globalLyricOffsetAlwaysApply: false, fullscreenPlayerElements: { like: true, addToPlaylist: true, diff --git a/src/stores/status.ts b/src/stores/status.ts index 815d640ff..420596905 100644 --- a/src/stores/status.ts +++ b/src/stores/status.ts @@ -11,6 +11,7 @@ import type { import type { RepeatModeType, ShuffleModeType } from "@/types/shared/play-mode"; import { isDevBuild } from "@/utils/env"; import { defineStore } from "pinia"; +import { useSettingStore } from "./setting"; interface StatusState { /** 菜单折叠状态 */ @@ -335,7 +336,14 @@ export const useStatusStore = defineStore("status", { getSongOffset(songId?: number): number { if (!songId) return 0; const offsetTime = this.currentTimeOffsetMap?.[songId] ?? 0; - return Math.floor(offsetTime * 1000); + const baseOffset = Math.floor(offsetTime * 1000); + + // 注意:这里需要动态导入 settingStore 避免循环依赖 + const settingStore = useSettingStore(); + if (settingStore.globalLyricOffsetEnabled && settingStore.globalLyricOffsetAlwaysApply) { + return baseOffset + settingStore.globalLyricOffsetValue; + } + return baseOffset; }, /** * 设置指定歌曲的偏移 @@ -362,12 +370,13 @@ export const useStatusStore = defineStore("status", { */ incSongOffset(songId?: number, delta: number = 500) { if (!songId) return; - const current = this.getSongOffset(songId); - const next = current + delta; - if (next === 0) { + const offsetTime = this.currentTimeOffsetMap?.[songId] ?? 0; + const currentLocal = Math.floor(offsetTime * 1000); + const nextLocal = currentLocal + delta; + if (nextLocal === 0) { delete this.currentTimeOffsetMap[songId]; } else { - this.setSongOffset(songId, next); + this.setSongOffset(songId, nextLocal); } }, /** 重置指定歌曲的偏移为 0 */