diff --git a/src/components/Setting/config/play.ts b/src/components/Setting/config/play.ts index e8cf74f1e..f0439b1f3 100644 --- a/src/components/Setting/config/play.ts +++ b/src/components/Setting/config/play.ts @@ -349,6 +349,11 @@ export const usePlaySettings = (): SettingConfig => { get: () => settingStore.useNextPrefetch, set: (v) => (settingStore.useNextPrefetch = v), }), + forceIf: { + condition: computed(() => settingStore.useGaplessPlayback), + forcedValue: true, + forcedDescription: "无缝播放已启用,需要保持预载开启", + }, }, { key: "memoryLastSeek", @@ -425,19 +430,32 @@ export const usePlaySettings = (): SettingConfig => { ], }, { - key: "enableAutomix", - label: "启用自动混音", - type: "switch", + key: "songTransitionMode", + label: "切歌过渡模式", + type: "select", tags: [{ text: "Beta", type: "warning" }], description: computed(() => settingStore.playbackEngine === "web-audio" - ? "是否启用自动混音功能" - : "自动混音功能仅在使用 Web Audio 引擎时可用", + ? "选择歌曲切换时的过渡方式" + : "过渡模式仅在使用 Web Audio 引擎时可用", ), + options: computed(() => [ + { label: "关闭", value: "off" }, + { + label: "自动混音 (Auto Mix)", + value: "automix", + disabled: !isElectron, + }, + { + label: "无缝播放 (Gapless)", + value: "gapless", + disabled: settingStore.audioEngine !== "element", + }, + ]), value: computed({ - get: () => settingStore.enableAutomix, + get: () => settingStore.songTransitionMode, set: (v) => { - if (v) { + if (v === "automix") { window.$dialog.warning({ title: "启用自动混音 (Beta)", content: @@ -445,30 +463,47 @@ export const usePlaySettings = (): SettingConfig => { positiveText: "开启", negativeText: "取消", onPositiveClick: () => { - settingStore.enableAutomix = true; + settingStore.songTransitionMode = "automix"; + }, + }); + } else if (v === "gapless") { + window.$dialog.warning({ + title: "启用无缝播放 (Beta)", + content: + "无缝播放会预解码下一首歌曲的音频数据,每首歌曲约占用 50-150MB 内存。如果设备内存较小,可能影响性能。该功能目前处于预览状态,有任何问题请提交 Issues。", + positiveText: "开启", + negativeText: "取消", + onPositiveClick: () => { + settingStore.songTransitionMode = "gapless"; }, }); } else { - settingStore.enableAutomix = v; + settingStore.songTransitionMode = v; } }, }), disabled: computed(() => settingStore.playbackEngine !== "web-audio"), - children: [ - { - key: "automixMaxAnalyzeTime", - label: "最大分析时间", - type: "input-number", - description: "单位秒,越长越精准但更耗时 (建议 60s)", - min: 5, - max: 300, - suffix: "s", - value: computed({ - get: () => settingStore.automixMaxAnalyzeTime, - set: (v) => (settingStore.automixMaxAnalyzeTime = v), - }), - }, - ], + condition: () => settingStore.songTransitionMode !== "off", + children: computed(() => { + if (settingStore.songTransitionMode === "automix") { + return [ + { + key: "automixMaxAnalyzeTime", + label: "最大分析时间", + type: "input-number" as const, + description: "单位秒,越长越精准但更耗时 (建议 60s)", + min: 5, + max: 300, + suffix: "s", + value: computed({ + get: () => settingStore.automixMaxAnalyzeTime, + set: (v: number) => (settingStore.automixMaxAnalyzeTime = v), + }), + }, + ]; + } + return []; + }), }, ], }, diff --git a/src/core/audio-player/BaseAudioPlayer.ts b/src/core/audio-player/BaseAudioPlayer.ts index 15dcb2a8b..75db262b3 100644 --- a/src/core/audio-player/BaseAudioPlayer.ts +++ b/src/core/audio-player/BaseAudioPlayer.ts @@ -182,14 +182,20 @@ export abstract class BaseAudioPlayer } const duration = options.fadeIn ? (options.fadeDuration ?? 0.5) : 0; + const target = this.volume * this.replayGain; - // 修复:如果是渐入,强制从 0 开始 + // 渐入时直接操作 gainNode,绕过 applyFadeTo 避免 cancelScheduledValues 取消刚设置的值 if (duration > 0 && this.gainNode && this.audioCtx) { - this.gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime); + const ct = this.audioCtx.currentTime; + this.gainNode.gain.cancelScheduledValues(ct); + this.gainNode.gain.setValueAtTime(0, ct); + const safeStart = ct + 0.02; + this.gainNode.gain.setValueAtTime(0, safeStart); + this.gainNode.gain.linearRampToValueAtTime(target, safeStart + duration); + } else { + this.applyFadeTo(target, 0); } - this.applyFadeTo(this.volume * this.replayGain, duration, options.fadeCurve); - try { await this.doPlay(); } catch (e) { @@ -274,8 +280,8 @@ export abstract class BaseAudioPlayer */ public stop() { this.cancelPendingPause(); - // 捕获可能产生的异步错误 - Promise.resolve(this.pause({ fadeOut: false })).catch(() => {}); + // 使用 keepContextRunning 防止挂起共享 AudioContext + Promise.resolve(this.pause({ fadeOut: false, keepContextRunning: true })).catch(() => {}); Promise.resolve(this.doSeek(0)).catch(() => {}); } diff --git a/src/core/automix/AutomixManager.ts b/src/core/automix/AutomixManager.ts index e7ca1193b..2b6350ab1 100644 --- a/src/core/automix/AutomixManager.ts +++ b/src/core/automix/AutomixManager.ts @@ -3,7 +3,7 @@ import { getSharedAudioContext } from "./SharedAudioContext"; import { useAudioManager } from "../player/AudioManager"; import { useSongManager } from "../player/SongManager"; import { usePlayerController } from "../player/PlayerController"; -import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores"; +import { useMusicStore, useSettingStore, useStatusStore } from "@/stores"; import type { AudioAnalysis, AutomixPlan, @@ -1063,37 +1063,7 @@ export class AutomixManager { } public getNextSongForAutomix(): { song: SongType; index: number } | null { - const dataStore = useDataStore(); - const statusStore = useStatusStore(); - const playerController = usePlayerController(); - - if (dataStore.playList.length === 0) return null; - - // 单曲循环模式下,下一首就是当前这首 - if (statusStore.repeatMode === "one") { - const currentSong = dataStore.playList[statusStore.playIndex]; - if (currentSong) { - return { song: currentSong, index: statusStore.playIndex }; - } - } - - if (dataStore.playList.length <= 1) return null; - - let nextIndex = statusStore.playIndex; - let attempts = 0; - const maxAttempts = dataStore.playList.length; - - while (attempts < maxAttempts) { - nextIndex++; - if (nextIndex >= dataStore.playList.length) nextIndex = 0; - - const nextSong = dataStore.playList[nextIndex]; - if (!playerController.shouldSkipSong(nextSong)) { - return { song: nextSong, index: nextIndex }; - } - attempts++; - } - return null; + return usePlayerController().getNextSongInfo(); } } diff --git a/src/core/gapless/AudioBufferPlayer.ts b/src/core/gapless/AudioBufferPlayer.ts new file mode 100644 index 000000000..8ec5dd0f4 --- /dev/null +++ b/src/core/gapless/AudioBufferPlayer.ts @@ -0,0 +1,245 @@ +import { AUDIO_EVENTS, BaseAudioPlayer } from "../audio-player/BaseAudioPlayer"; +import type { EngineCapabilities } from "../audio-player/IPlaybackEngine"; + +/** + * 基于 AudioBuffer 的播放器 + * + * 用于无缝播放场景,播放预解码的 AudioBuffer + * 通过 AudioBufferSourceNode 实现采样级精确调度 + */ +export class AudioBufferPlayer extends BaseAudioPlayer { + /** 预解码的音频缓冲区 */ + private buffer: AudioBuffer | null = null; + /** 当前活动的 SourceNode */ + private sourceNode: AudioBufferSourceNode | null = null; + /** 是否处于暂停状态 */ + private _paused = true; + /** 播放速率 */ + private _rate = 1.0; + + /** 锚点偏移量(秒) */ + private anchorOffset = 0; + /** 锚点时刻的 AudioContext 时间 */ + private anchorContextTime = 0; + + /** timeupdate 定时器 */ + private timeupdateTimer: ReturnType | null = null; + + /** 引擎能力描述 */ + public override readonly capabilities: EngineCapabilities = { + supportsRate: true, + supportsSinkId: false, + supportsEqualizer: true, + supportsSpectrum: true, + }; + + constructor() { + super(); + } + + /** + * 注入预解码的 AudioBuffer + */ + public setBuffer(buffer: AudioBuffer) { + this.buffer = buffer; + } + + // 音频图谱初始化回调(无需创建 MediaElement) + protected onGraphInitialized(): void { + // 空实现 + } + + // AudioBufferPlayer 不支持 URL 加载 + public async load(_url: string): Promise { + // 空实现,buffer 通过 setBuffer 注入 + } + + /** + * 创建并启动 SourceNode + */ + protected async doPlay(): Promise { + if (!this.audioCtx) return; + + this.stopSource(); + if (!this.createAndStartSource(this.anchorOffset)) return; + + this.anchorContextTime = this.audioCtx.currentTime; + this._paused = false; + this.startTimeupdateTimer(); + this.dispatch(AUDIO_EVENTS.PLAY); + } + + /** + * 精确调度播放(无缝衔接时使用) + * @param offset 音频偏移量(秒) + * @param when AudioContext 时间点 + */ + public scheduleStart(offset: number, when: number) { + this.stopSource(); + + this.anchorOffset = offset; + this.anchorContextTime = when; + if (!this.createAndStartSource(offset, when)) return; + + this._paused = false; + this.startTimeupdateTimer(); + } + + protected doPause(): void { + if (this._paused) return; + // 记录当前位置 + this.anchorOffset = this.currentTime; + this.stopSource(); + this._paused = true; + this.stopTimeupdateTimer(); + this.dispatch(AUDIO_EVENTS.PAUSE); + } + + protected doSeek(time: number): void { + this.anchorOffset = Math.max(0, Math.min(time, this.duration)); + if (this.audioCtx) { + this.anchorContextTime = this.audioCtx.currentTime; + } + + // 如果正在播放,重新创建 source + if (!this._paused) { + this.stopSource(); + this.createAndStartSource(this.anchorOffset); + } + + this.dispatch(AUDIO_EVENTS.SEEKED); + } + + public setRate(value: number): void { + // 先用旧速率计算当前位置,再更新锚点 + if (this.audioCtx && !this._paused) { + const wallDelta = this.audioCtx.currentTime - this.anchorContextTime; + this.anchorOffset = Math.max(0, Math.min(this.anchorOffset + wallDelta * this._rate, this.duration)); + this.anchorContextTime = this.audioCtx.currentTime; + } + + this._rate = value; + + if (this.sourceNode) { + this.sourceNode.playbackRate.value = value; + } + } + + public getRate(): number { + return this._rate; + } + + protected async doSetSinkId(_deviceId: string): Promise { + // AudioBufferPlayer 不支持独立设备切换,依赖共享 AudioContext + } + + public get src(): string { + return ""; + } + + public get duration(): number { + return this.buffer?.duration ?? 0; + } + + public get currentTime(): number { + if (this._paused || !this.audioCtx) return this.anchorOffset; + const wallDelta = this.audioCtx.currentTime - this.anchorContextTime; + return Math.max(0, Math.min(this.anchorOffset + wallDelta * this._rate, this.duration)); + } + + public get paused(): boolean { + return this._paused; + } + + public getErrorCode(): number { + return 0; + } + + /** + * 在指定的 AudioContext 时间点设置增益值 + * 用于 GaplessManager 在调度时预设音量,避免直接访问 protected gainNode + */ + public setGainAtTime(value: number, when: number): void { + if (this.gainNode) { + this.gainNode.gain.setValueAtTime(value, when); + } + } + + /** + * 销毁引擎,释放内存 + */ + public override destroy(): void { + this.stopSource(); + this.stopTimeupdateTimer(); + this.buffer = null; + this._paused = true; + super.destroy(); + } + + /** + * 创建、连接并启动 SourceNode,同时设置 onended 回调 + * @param offset 音频偏移量(秒) + * @param when AudioContext 时间点(0 表示立即播放) + * @returns 创建的 source,如果前置条件不满足则返回 null + */ + private createAndStartSource(offset: number, when: number = 0): AudioBufferSourceNode | null { + if (!this.buffer || !this.audioCtx || !this.inputNode) return null; + + const source = this.audioCtx.createBufferSource(); + source.buffer = this.buffer; + source.playbackRate.value = this._rate; + source.connect(this.inputNode); + source.start(when, offset); + + source.onended = () => { + if (this.sourceNode === source && !this._paused) { + const elapsed = this.currentTime; + const dur = this.duration; + if (dur > 0 && elapsed < dur - 0.5) { + console.warn( + `[AudioBufferPlayer] source.onended 提前触发 (elapsed=${elapsed.toFixed(2)}, duration=${dur.toFixed(2)})`, + ); + return; + } + this._paused = true; + this.stopTimeupdateTimer(); + this.dispatch(AUDIO_EVENTS.ENDED); + } + }; + + this.sourceNode = source; + return source; + } + + /** 停止当前 SourceNode */ + private stopSource() { + if (this.sourceNode) { + try { + this.sourceNode.onended = null; + this.sourceNode.stop(); + this.sourceNode.disconnect(); + } catch { + // 可能已经停止 + } + this.sourceNode = null; + } + } + + /** 启动 timeupdate 定时器 */ + private startTimeupdateTimer() { + this.stopTimeupdateTimer(); + this.timeupdateTimer = setInterval(() => { + if (!this._paused) { + this.dispatch(AUDIO_EVENTS.TIME_UPDATE); + } + }, 200); + } + + /** 停止 timeupdate 定时器 */ + private stopTimeupdateTimer() { + if (this.timeupdateTimer) { + clearInterval(this.timeupdateTimer); + this.timeupdateTimer = null; + } + } +} diff --git a/src/core/gapless/GaplessManager.ts b/src/core/gapless/GaplessManager.ts new file mode 100644 index 000000000..84b2dcb24 --- /dev/null +++ b/src/core/gapless/GaplessManager.ts @@ -0,0 +1,246 @@ +import { getSharedAudioContext } from "../automix/SharedAudioContext"; +import { AudioBufferPlayer } from "./AudioBufferPlayer"; + +/** + * 无缝播放管理器 + * + * 管理预解码 → 调度 → 提交 → 清除的完整生命周期 + * 通过预解码下一首歌曲的 AudioBuffer 并精确调度实现无缝衔接 + */ +class GaplessManager { + /** 预载的 AudioBufferPlayer */ + private player: AudioBufferPlayer | null = null; + /** 当前预载的 URL */ + private _url: string | null = null; + /** 预载对应的播放列表索引 */ + private _nextIndex: number = -1; + /** 预载对应的歌曲 ID */ + private _nextSongId: number | string | null = null; + /** 是否正在预载中 */ + private _isPreloading = false; + /** 是否已就绪(解码完成) */ + private _isReady = false; + /** 是否已调度播放 */ + private _isScheduled = false; + /** 用于取消 fetch 的 AbortController */ + private abortController: AbortController | null = null; + + /** 当前预载的 URL */ + get url() { + return this._url; + } + + /** 预载对应的下一首索引 */ + get nextIndex() { + return this._nextIndex; + } + + /** 更新下一首索引(当歌曲未变但列表发生偏移时动态校准索引) */ + updateNextIndex(newIndex: number) { + if (this._nextIndex !== newIndex) { + this._nextIndex = newIndex; + } + } + + /** 预载对应的歌曲 ID */ + get nextSongId() { + return this._nextSongId; + } + + /** 是否正在预载中 */ + get isPreloading() { + return this._isPreloading; + } + + /** 是否已就绪 */ + get isReady() { + return this._isReady; + } + + /** 是否已调度 */ + get isScheduled() { + return this._isScheduled; + } + + /** + * 预载下一首歌曲 + * fetch 音频数据 → decodeAudioData → 创建 AudioBufferPlayer + * @param url 音频 URL + * @param nextIndex 下一首在播放列表中的索引 + * @param songId 歌曲的唯一 ID + */ + async preload(url: string, nextIndex: number, songId: number | string, songName?: string) { + // 如果已经在预载相同 URL,跳过 + if (this._url === url && (this._isReady || this._isPreloading)) { + return; + } + + // 清除之前的预载 + this.clear(); + + this._url = url; + this._nextIndex = nextIndex; + this._nextSongId = songId; + this._isPreloading = true; + + const abortController = new AbortController(); + this.abortController = abortController; + + try { + const label = songName ? `"${songName}"` : `index=${nextIndex}`; + console.log(`[GaplessManager] 开始预载: ${label}`); + + const response = await fetch(url, { + signal: abortController.signal, + }); + + // 检查是否已取消 + if (abortController.signal.aborted) return; + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const rawSize = arrayBuffer.byteLength; + + // 再次检查是否已取消 + if (abortController.signal.aborted) return; + + const audioCtx = getSharedAudioContext(); + const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); + + // 最后检查是否已取消 + if (abortController.signal.aborted) return; + + // 创建 AudioBufferPlayer + const player = new AudioBufferPlayer(); + player.init(); + player.setBuffer(audioBuffer); + + this.player = player; + this._isReady = true; + this._isPreloading = false; + + // 计算解码后 PCM 内存占用 + const pcmBytes = audioBuffer.length * audioBuffer.numberOfChannels * 4; // Float32 = 4 bytes + + console.log( + `[GaplessManager] 预载完成: ${label}, duration=${audioBuffer.duration.toFixed(1)}s, raw=${(rawSize / 1024 / 1024).toFixed(1)}MB, pcm=${(pcmBytes / 1024 / 1024).toFixed(1)}MB`, + ); + } catch (e) { + if ((e as Error).name === "AbortError") { + console.log("[GaplessManager] 预载已取消"); + } else { + console.warn("[GaplessManager] 预载失败:", e); + } + this._isPreloading = false; + // 预载失败不影响正常播放,保留 url 和 nextIndex 供外部判断 + } + } + + /** + * 调度无缝过渡 + * 在当前歌曲即将结束时,精确调度下一首的 AudioBufferSourceNode + * @param remaining 当前歌曲剩余时间(秒) + * @param volume 当前音量(0-1) + */ + schedule(remaining: number, volume: number) { + if (!this._isReady || this._isScheduled || !this.player) return; + + const audioCtx = getSharedAudioContext(); + const when = audioCtx.currentTime + remaining; + + // 预设音量,不调用 setVolume 避免污染 volume 字段 + this.player.setGainAtTime(volume, when); + + // 精确调度 + this.player.scheduleStart(0, when); + this._isScheduled = true; + + console.log( + `[GaplessManager] 已调度过渡: remaining=${remaining.toFixed(2)}s, when=${when.toFixed(3)}`, + ); + } + + /** + * 提交无缝过渡 + * 返回 AudioBufferPlayer,调用方接管其生命周期 + * @returns AudioBufferPlayer 或 null + */ + commit(): AudioBufferPlayer | null { + if (!this._isScheduled || !this.player) return null; + + const player = this.player; + + // 重置状态但不 destroy player(由调用方接管) + this.player = null; + this._url = null; + this._nextIndex = -1; + this._nextSongId = null; + this._isReady = false; + this._isScheduled = false; + this._isPreloading = false; + this.abortController = null; + + console.log("[GaplessManager] 已提交过渡"); + return player; + } + + /** + * 取消已调度的过渡,保留预载数据 + * 用于暂停、拖动进度条等场景 + */ + cancel() { + if (!this._isScheduled || !this.player) return; + + // 使用 keepContextRunning 暂停,不冻结共享 AudioContext + this.player.pause({ keepContextRunning: true }); + this._isScheduled = false; + + console.log("[GaplessManager] 已取消调度"); + } + + /** + * 完全清除:中止 fetch + 销毁 player + 重置所有状态 + * 播放列表变更、手动切歌等场景调用 + */ + clear() { + // 中止进行中的 fetch + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + // 销毁 player + if (this.player) { + try { + this.player.destroy(); + } catch { + // 忽略销毁错误 + } + this.player = null; + } + + // 重置所有状态 + this._url = null; + this._nextIndex = -1; + this._nextSongId = null; + this._isReady = false; + this._isScheduled = false; + this._isPreloading = false; + } +} + +const GAPLESS_MANAGER_KEY = "__SPLAYER_GAPLESS_MANAGER__"; + +/** + * 获取 GaplessManager 单例 + */ +export const useGaplessManager = (): GaplessManager => { + const win = window as Window & { [GAPLESS_MANAGER_KEY]?: GaplessManager }; + if (!win[GAPLESS_MANAGER_KEY]) { + win[GAPLESS_MANAGER_KEY] = new GaplessManager(); + } + return win[GAPLESS_MANAGER_KEY]; +}; diff --git a/src/core/player/AudioManager.ts b/src/core/player/AudioManager.ts index 3ed68ee1b..b8a3b20b0 100644 --- a/src/core/player/AudioManager.ts +++ b/src/core/player/AudioManager.ts @@ -12,6 +12,8 @@ import type { PlayOptions, } from "../audio-player/IPlaybackEngine"; import { MpvPlayer, useMpvPlayer } from "../audio-player/MpvPlayer"; +import { AudioBufferPlayer } from "../gapless/AudioBufferPlayer"; +import { useGaplessManager } from "../gapless/GaplessManager"; import { getSharedAudioContext } from "../automix/SharedAudioContext"; /** @@ -32,6 +34,8 @@ class AudioManager extends TypedEventTarget implements IPlaybackE /** 主音量 (用于 Crossfade 初始化) */ private _masterVolume: number = 1.0; + /** ReplayGain 缓存 (引擎切换时同步) */ + private _replayGain: number = 1.0; /** 当前引擎类型:element | ffmpeg | mpv */ public readonly engineType: "element" | "ffmpeg" | "mpv"; @@ -113,6 +117,7 @@ class AudioManager extends TypedEventTarget implements IPlaybackE */ public destroy(): void { this.clearPendingSwitch(); + useGaplessManager().clear(); if (this.cleanupListeners) { this.cleanupListeners(); this.cleanupListeners = null; @@ -124,6 +129,15 @@ class AudioManager extends TypedEventTarget implements IPlaybackE * 加载并播放音频 */ public async play(url?: string, options?: PlayOptions): Promise { + // 如果当前引擎是 AudioBufferPlayer 且需要加载新 URL,恢复默认引擎 + if (url && this.engine instanceof AudioBufferPlayer) { + this.restoreDefaultEngine(); + } + // 如果 gapless 预载的 URL 与当前不匹配,清除预载 + const gaplessManager = useGaplessManager(); + if (url && gaplessManager.url && gaplessManager.url !== url) { + gaplessManager.clear(); + } await this.engine.play(url, options); } @@ -301,6 +315,70 @@ class AudioManager extends TypedEventTarget implements IPlaybackE this.engine.stop(); } + /** + * 恢复默认播放引擎 + * 当从 AudioBufferPlayer 切回正常播放时调用 + */ + private restoreDefaultEngine() { + const oldEngine = this.engine; + let newEngine: IPlaybackEngine; + if (this.engineType === "ffmpeg") { + newEngine = new FFmpegAudioPlayer(); + } else { + newEngine = new AudioElementPlayer(); + } + newEngine.init(); + if (this.cleanupListeners) { + this.cleanupListeners(); + this.cleanupListeners = null; + } + this.engine = newEngine; + this.bindEngineEvents(); + this.engine.setVolume(this._masterVolume); + this.engine.setReplayGain?.(this._replayGain); + try { + oldEngine.destroy(); + } catch { + // ignore + } + console.log("[AudioManager] 已从 AudioBufferPlayer 恢复默认引擎"); + } + + /** + * 提交无缝过渡 + * 将 GaplessManager 中预调度的 AudioBufferPlayer 接管为当前引擎 + * @returns 是否成功提交 + */ + public commitGaplessTransition(): boolean { + const gaplessManager = useGaplessManager(); + const newPlayer = gaplessManager.commit(); + if (!newPlayer) return false; + const oldEngine = this.engine; + if (this.cleanupListeners) { + this.cleanupListeners(); + this.cleanupListeners = null; + } + this.engine = newPlayer; + this.bindEngineEvents(); + const audioCtx = getSharedAudioContext(); + if (audioCtx.state === "suspended") { + audioCtx.resume().catch(() => {}); + } + this.engine.setVolume(this._masterVolume); + this.engine.setReplayGain?.(this._replayGain); + this.dispatch(AUDIO_EVENTS.PLAY, undefined); + this.dispatch(AUDIO_EVENTS.TIME_UPDATE, undefined); + setTimeout(() => { + try { + oldEngine.destroy(); + } catch { + // ignore + } + }, 100); + console.log("[AudioManager] 已提交无缝过渡"); + return true; + } + private clearPendingSwitch() { if (this.pendingSwitchTimer) { clearTimeout(this.pendingSwitchTimer); @@ -332,6 +410,7 @@ class AudioManager extends TypedEventTarget implements IPlaybackE * @param gain 线性增益值 */ public setReplayGain(gain: number): void { + this._replayGain = gain; this.engine.setReplayGain?.(gain); } diff --git a/src/core/player/PlayerController.ts b/src/core/player/PlayerController.ts index f9f32909d..9fd9b0fef 100644 --- a/src/core/player/PlayerController.ts +++ b/src/core/player/PlayerController.ts @@ -14,6 +14,7 @@ import { calculateProgress } from "@/utils/time"; import type { LyricLine } from "@applemusic-like-lyrics/lyric"; import { type DebouncedFunc, throttle } from "lodash-es"; import { useBlobURLManager } from "../resource/BlobURLManager"; +import { useGaplessManager } from "../gapless/GaplessManager"; import { useAudioManager } from "./AudioManager"; import { useAutomixManager } from "@/core/automix/AutomixManager"; import { useLyricManager } from "./LyricManager"; @@ -194,6 +195,7 @@ class PlayerController { musicStore.playSong = song; statusStore.currentTime = startSeek; + statusStore.duration = song.duration || 0; // 重置进度 statusStore.progress = 0; statusStore.lyricIndex = -1; @@ -257,6 +259,7 @@ class PlayerController { // 重置过渡状态 this.isTransitioning = false; useAutomixManager().resetNextAnalysisCache(); + useGaplessManager().clear(); this.currentAnalysisKey = null; this.currentAudioSource = null; // 生成新的请求标识 @@ -575,7 +578,6 @@ class PlayerController { const dataStore = useDataStore(); const musicStore = useMusicStore(); const settingStore = useSettingStore(); - const songManager = useSongManager(); // 记录播放历史 (非电台) if (song.type !== "radio") dataStore.setHistory(song); // 更新歌曲数据 @@ -589,7 +591,9 @@ class PlayerController { } // 预载下一首 - if (settingStore.useNextPrefetch) songManager.prefetchNextSong(); + if (settingStore.useNextPrefetch || settingStore.useGaplessPlayback) { + this.refreshNextPreload(); + } // Last.fm Scrobbler if (settingStore.lastfm.enabled && settingStore.isLastfmConfigured) { @@ -599,6 +603,66 @@ class PlayerController { } } + /** + * 统一的下一首预载入口 + * 1. 始终触发 URL 预取 (prefetchNextSong) — 用于所有模式 + * 2. 当无缝播放启用时,额外触发 AudioBuffer 预载 + */ + public refreshNextPreload() { + const settingStore = useSettingStore(); + if (!settingStore.useNextPrefetch && !settingStore.useGaplessPlayback) return; + const songManager = useSongManager(); + // 始终执行 URL 预取(cover、lyrics、URL cache) + songManager.prefetchNextSong().then(async () => { + // gapless 额外触发 AudioBuffer 预解码 + if ( + !settingStore.useGaplessPlayback || + useAudioManager().engineType !== "element" + ) + return; + // 使用共享的 getNextSongInfo 获取准确的下一首(处理 DJ 跳过等) + const nextInfo = this.getNextSongInfo(); + if (!nextInfo) return; + // 获取实际下一首的 URL(prefetchNextSong 可能因 DJ 跳过预取了错误的歌曲) + const audioSource = await songManager.getAudioSource(nextInfo.song); + if (!audioSource.url) return; + useGaplessManager().preload(audioSource.url, nextInfo.index, nextInfo.song.id, nextInfo.song.name); + }); + } + + /** + * 处理无缝过渡切歌 + * 引擎已由 AudioManager.commitGaplessTransition 切换,这里只做 UI 和状态同步 + */ + private async handleGaplessSwitch(preloadedIndex: number) { + const statusStore = useStatusStore(); + const dataStore = useDataStore(); + const audioManager = useAudioManager(); + statusStore.playIndex = preloadedIndex; + const song = dataStore.playList[preloadedIndex]; + if (!song) { + console.warn("[Gapless] 无法获取预载索引对应的歌曲,回退到标准切歌"); + this.nextOrPrev("next", true, true); + return; + } + this.setupSongUI(song, 0); + // 同步速率 + const rate = statusStore.playRate; + if (rate !== 1.0) { + audioManager.setRate(rate); + } + // 同步 EQ + if (isElectron && statusStore.eqEnabled) { + const bands = statusStore.eqBands; + if (bands && bands.length === 10) { + bands.forEach((val, idx) => audioManager.setFilterGain(idx, val)); + } + } + statusStore.playLoading = false; + await this.afterPlaySetup(song); + console.log(`[${song.id}] 无缝过渡完成: ${song.name}`); + } + /** * 解析本地歌曲元信息 * @param path 歌曲路径 @@ -715,6 +779,10 @@ class PlayerController { audioManager.addEventListener("pause", () => { statusStore.playStatus = false; useAutomixManager().resetAutomixScheduling("IDLE"); + // 仅在非无缝过渡调度时取消(歌曲自然结束会先触发 pause 再触发 ended) + if (!useGaplessManager().isScheduled) { + useGaplessManager().cancel(); + } playerIpc.sendMediaPlayState("Paused"); mediaSessionManager.updatePlaybackStatus(false); if (!isElectron) window.document.title = "SPlayer"; @@ -728,6 +796,7 @@ class PlayerController { // 拖动进度条 audioManager.addEventListener("seeking", () => { useAutomixManager().resetAutomixScheduling("MONITORING"); + useGaplessManager().cancel(); }); // 播放结束 audioManager.addEventListener("ended", () => { @@ -737,7 +806,16 @@ class PlayerController { lastfmScrobbler.stop(); // 检查定时关闭 if (this.checkAutoClose()) return; - // 自动播放下一首 + // 无缝过渡 + const gaplessManager = useGaplessManager(); + if (gaplessManager.isScheduled) { + const nextIndex = gaplessManager.nextIndex; + if (audioManager.commitGaplessTransition()) { + this.handleGaplessSwitch(nextIndex); + return; + } + } + // 回退到标准切歌 this.nextOrPrev("next", true, true); }); // 进度更新 @@ -753,6 +831,34 @@ class PlayerController { const currentTime = Math.floor(rawTime * 1000); const duration = Math.floor(audioManager.duration * 1000) || statusStore.duration; useAutomixManager().updateAutomixMonitoring(); + // 无缝播放:懒校验 + 调度(复用 automix 监控循环模式) + if ( + settingStore.useGaplessPlayback && + audioManager.engineType === "element" && + statusStore.repeatMode !== "one" && + duration > 0 + ) { + const gaplessManager = useGaplessManager(); + const nextInfo = this.getNextSongInfo(); + // 懒校验:预载的下一首是否仍然匹配 + if (gaplessManager.nextIndex >= 0 && nextInfo) { + if (gaplessManager.nextSongId !== nextInfo.song.id) { + // 歌曲变了,清除并重新预载 + gaplessManager.clear(); + this.refreshNextPreload(); + } else if (gaplessManager.nextIndex !== nextInfo.index) { + // 同一首歌但索引偏移了(如列表重排),更新索引 + gaplessManager.updateNextIndex(nextInfo.index); + } + } + // 调度:剩余 ≤2s + const remaining = (duration - currentTime) / 1000; + if (remaining > 0 && remaining <= 2.0) { + if (gaplessManager.isReady && !gaplessManager.isScheduled) { + gaplessManager.schedule(remaining, statusStore.playVolume); + } + } + } // 计算歌词索引 const songId = musicStore.playSong?.id; const offset = statusStore.getSongOffset(songId); @@ -927,8 +1033,8 @@ class PlayerController { if (statusStore.playStatus) return; // 清除 MPV 强制暂停状态(如果是 MPV 引擎) audioManager.clearForcePaused(); - // 如果没有源,尝试重新初始化当前歌曲 - if (!audioManager.src) { + // 如果没有源(兼容 AudioBufferPlayer 的空 src),尝试重新初始化当前歌曲 + if (!audioManager.src && !(audioManager.duration > 0)) { await this.playSong({ autoPlay: true, seek: statusStore.currentTime, @@ -1014,6 +1120,7 @@ class PlayerController { let attempts = 0; const maxAttempts = playListLength; // Fuck DJ Mode: 寻找下一个不被跳过的歌曲 + const skippedNames: string[] = []; while (attempts < maxAttempts) { nextIndex += type === "next" ? 1 : -1; // 边界处理 (索引越界) @@ -1023,8 +1130,16 @@ class PlayerController { if (!this.shouldSkipSong(nextSong)) { break; } + skippedNames.push(nextSong.name || `#${nextIndex}`); attempts++; } + if (skippedNames.length > 0) { + const display = skippedNames.length <= 3 + ? skippedNames.join("、") + : `${skippedNames.slice(0, 3).join("、")} 等 ${skippedNames.length} 首`; + console.log(`[Fuck DJ] Skipping: ${skippedNames.join(", ")}`); + window.$message.warning(`已跳过 DJ/抖音 歌曲: ${display}`); + } if (attempts >= maxAttempts) { window.$message.warning("播放列表中没有可播放的歌曲"); audioManager.stop(); @@ -1160,6 +1275,34 @@ class PlayerController { return DJ_MODE_KEYWORDS.some((k) => fullText.includes(k.toUpperCase())); } + /** + * 获取下一首歌曲信息(共享) + * automix、gapless、prefetchNextSong 均可使用 + * 处理单曲循环、DJ 跳过等逻辑 + */ + public getNextSongInfo(): { song: SongType; index: number } | null { + const dataStore = useDataStore(); + const statusStore = useStatusStore(); + if (dataStore.playList.length === 0) return null; + if (statusStore.repeatMode === "one") { + const current = dataStore.playList[statusStore.playIndex]; + return current ? { song: current, index: statusStore.playIndex } : null; + } + if (dataStore.playList.length <= 1) return null; + let nextIndex = statusStore.playIndex; + let attempts = 0; + while (attempts < dataStore.playList.length) { + nextIndex++; + if (nextIndex >= dataStore.playList.length) nextIndex = 0; + const nextSong = dataStore.playList[nextIndex]; + if (!this.shouldSkipSong(nextSong)) { + return { song: nextSong, index: nextIndex }; + } + attempts++; + } + return null; + } + /** * 更新播放列表并播放 * @param data 歌曲列表 @@ -1269,7 +1412,11 @@ class PlayerController { if (play) { await this.togglePlayIndex(songIndex, true); } else { - window.$message.success("已添加至下一首播放"); + if (this.shouldSkipSong(song)) { + window.$message.warning(`已添加至播放列表,但该歌曲将被 Fuck DJ Mode 跳过: ${song.name}`); + } else { + window.$message.success("已添加至下一首播放"); + } } } diff --git a/src/stores/migrations/settingMigrations.ts b/src/stores/migrations/settingMigrations.ts index c819027d1..905cd0b0a 100644 --- a/src/stores/migrations/settingMigrations.ts +++ b/src/stores/migrations/settingMigrations.ts @@ -6,7 +6,7 @@ import type { SettingState } from "../setting"; /** * 当前设置 Schema 版本号 */ -export const CURRENT_SETTING_SCHEMA_VERSION = 11; +export const CURRENT_SETTING_SCHEMA_VERSION = 12; /** * 迁移函数类型 @@ -191,4 +191,13 @@ export const settingMigrations: Record = { uncensorMaskedProfanity: false, }; }, + 12: (state) => { + interface OldSettingState extends Partial { + enableAutomix?: boolean; + } + const oldState = state as OldSettingState; + return { + songTransitionMode: oldState.enableAutomix ? "automix" : "off", + }; + }, }; diff --git a/src/stores/setting.ts b/src/stores/setting.ts index 0101f5708..2bb0f0ea6 100644 --- a/src/stores/setting.ts +++ b/src/stores/setting.ts @@ -495,8 +495,8 @@ export interface SettingState { disableAiAudio: boolean; /** Fuck DJ: 开启后自动跳过 DJ 歌曲 */ disableDjMode: boolean; - /** 启用自动混音 */ - enableAutomix: boolean; + /** 切歌过渡模式 */ + songTransitionMode: "off" | "automix" | "gapless"; /** 自动混音最大分析时间 (秒) */ automixMaxAnalyzeTime: number; /** 启用全局错误弹窗 */ @@ -784,7 +784,7 @@ export const useSettingStore = defineStore("setting", { streamingEnabled: false, disableAiAudio: false, disableDjMode: false, - enableAutomix: false, + songTransitionMode: "off" as "off" | "automix" | "gapless", automixMaxAnalyzeTime: 60, enableGlobalErrorDialog: true, macos: { @@ -808,6 +808,18 @@ export const useSettingStore = defineStore("setting", { const { lastfm } = state; return Boolean(lastfm.apiKey && lastfm.apiSecret); }, + /** + * 是否启用自动混音(向后兼容 getter) + */ + enableAutomix(state): boolean { + return state.songTransitionMode === "automix"; + }, + /** + * 是否启用无缝播放(向后兼容 getter) + */ + useGaplessPlayback(state): boolean { + return state.songTransitionMode === "gapless"; + }, }, actions: { /**