2025-11-08 14:26:04 +08:00
|
|
|
|
import { defineStore } from 'pinia';
|
|
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
|
|
|
|
|
|
|
|
import { audioService } from '@/services/audioService';
|
2026-02-04 20:10:28 +08:00
|
|
|
|
import type { AudioOutputDevice } from '@/types/audio';
|
2026-03-29 13:18:05 +08:00
|
|
|
|
import type { SongResult } from '@/types/music';
|
2026-05-17 23:07:58 +08:00
|
|
|
|
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
|
|
|
|
|
|
import { minifySong } from '@/utils/persistedSong';
|
2025-11-08 14:26:04 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 核心播放控制 Store
|
|
|
|
|
|
* 负责:播放/暂停、当前歌曲、音频URL、音量、播放速度、全屏状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const usePlayerCoreStore = defineStore(
|
|
|
|
|
|
'playerCore',
|
|
|
|
|
|
() => {
|
|
|
|
|
|
// ==================== 状态 ====================
|
|
|
|
|
|
const play = ref(false);
|
|
|
|
|
|
const isPlay = ref(false);
|
|
|
|
|
|
const playMusic = ref<SongResult>({} as SongResult);
|
|
|
|
|
|
const playMusicUrl = ref('');
|
|
|
|
|
|
const musicFull = ref(false);
|
|
|
|
|
|
const playbackRate = ref(1.0);
|
|
|
|
|
|
const volume = ref(1);
|
2026-04-26 21:47:11 +08:00
|
|
|
|
const isMuted = ref(false);
|
2025-11-21 01:18:19 +08:00
|
|
|
|
const userPlayIntent = ref(false); // 用户是否想要播放
|
2026-03-22 16:49:00 +08:00
|
|
|
|
const isFmPlaying = ref(false); // 是否正在播放私人FM
|
2025-11-08 14:26:04 +08:00
|
|
|
|
|
2026-02-04 20:10:28 +08:00
|
|
|
|
// 音频输出设备
|
|
|
|
|
|
const audioOutputDeviceId = ref<string>(
|
|
|
|
|
|
localStorage.getItem('audioOutputDeviceId') || 'default'
|
|
|
|
|
|
);
|
|
|
|
|
|
const availableAudioDevices = ref<AudioOutputDevice[]>([]);
|
|
|
|
|
|
|
2025-11-08 14:26:04 +08:00
|
|
|
|
// ==================== Computed ====================
|
|
|
|
|
|
const currentSong = computed(() => playMusic.value);
|
|
|
|
|
|
const isPlaying = computed(() => isPlay.value);
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Actions ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置播放状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setIsPlay = (value: boolean) => {
|
|
|
|
|
|
isPlay.value = value;
|
|
|
|
|
|
play.value = value;
|
|
|
|
|
|
window.electron?.ipcRenderer.send('update-play-state', value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置全屏状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setMusicFull = (value: boolean) => {
|
|
|
|
|
|
musicFull.value = value;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置播放速度
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setPlaybackRate = (rate: number) => {
|
|
|
|
|
|
playbackRate.value = rate;
|
|
|
|
|
|
audioService.setPlaybackRate(rate);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置音量
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setVolume = (newVolume: number) => {
|
|
|
|
|
|
const normalizedVolume = Math.max(0, Math.min(1, newVolume));
|
|
|
|
|
|
volume.value = normalizedVolume;
|
2026-04-26 21:47:11 +08:00
|
|
|
|
// 用户调高音量时自动解除静音
|
|
|
|
|
|
if (isMuted.value && normalizedVolume > 0) {
|
|
|
|
|
|
isMuted.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
audioService.setVolume(isMuted.value ? 0 : normalizedVolume);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置静音状态(不改变 volume,仅控制音频输出)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setMuted = (value: boolean) => {
|
|
|
|
|
|
if (isMuted.value === value) return;
|
|
|
|
|
|
isMuted.value = value;
|
|
|
|
|
|
audioService.setVolume(isMuted.value ? 0 : volume.value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换静音
|
|
|
|
|
|
*/
|
|
|
|
|
|
const toggleMute = () => {
|
|
|
|
|
|
setMuted(!isMuted.value);
|
2025-11-08 14:26:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取音量
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getVolume = () => volume.value;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 增加音量
|
|
|
|
|
|
*/
|
|
|
|
|
|
const increaseVolume = (step: number = 0.1) => {
|
|
|
|
|
|
const newVolume = Math.min(1, volume.value + step);
|
|
|
|
|
|
setVolume(newVolume);
|
|
|
|
|
|
return newVolume;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 减少音量
|
|
|
|
|
|
*/
|
|
|
|
|
|
const decreaseVolume = (step: number = 0.1) => {
|
|
|
|
|
|
const newVolume = Math.max(0, volume.value - step);
|
|
|
|
|
|
setVolume(newVolume);
|
|
|
|
|
|
return newVolume;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 暂停播放
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handlePause = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const currentSound = audioService.getCurrentSound();
|
|
|
|
|
|
if (currentSound) {
|
|
|
|
|
|
currentSound.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
setPlayMusic(false);
|
|
|
|
|
|
userPlayIntent.value = false;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('暂停播放失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置播放/暂停
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setPlayMusic = async (value: boolean | SongResult) => {
|
|
|
|
|
|
if (typeof value === 'boolean') {
|
|
|
|
|
|
setIsPlay(value);
|
|
|
|
|
|
userPlayIntent.value = value;
|
|
|
|
|
|
} else {
|
2026-03-29 13:18:05 +08:00
|
|
|
|
const { playTrack } = await import('@/services/playbackController');
|
|
|
|
|
|
await playTrack(value);
|
2025-11-08 14:26:04 +08:00
|
|
|
|
play.value = true;
|
|
|
|
|
|
isPlay.value = true;
|
|
|
|
|
|
userPlayIntent.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-04 20:10:28 +08:00
|
|
|
|
// ==================== 音频输出设备管理 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 刷新可用音频输出设备列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
const refreshAudioDevices = async () => {
|
|
|
|
|
|
availableAudioDevices.value = await audioService.getAudioOutputDevices();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换音频输出设备
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setAudioOutputDevice = async (deviceId: string): Promise<boolean> => {
|
|
|
|
|
|
const success = await audioService.setAudioOutputDevice(deviceId);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
audioOutputDeviceId.value = deviceId;
|
|
|
|
|
|
}
|
|
|
|
|
|
return success;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化设备变化监听
|
|
|
|
|
|
*/
|
|
|
|
|
|
const initAudioDeviceListener = () => {
|
|
|
|
|
|
if (navigator.mediaDevices) {
|
|
|
|
|
|
navigator.mediaDevices.addEventListener('devicechange', async () => {
|
|
|
|
|
|
await refreshAudioDevices();
|
|
|
|
|
|
const exists = availableAudioDevices.value.some(
|
|
|
|
|
|
(d) => d.deviceId === audioOutputDeviceId.value
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!exists && audioOutputDeviceId.value !== 'default') {
|
|
|
|
|
|
await setAudioOutputDevice('default');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-08 14:26:04 +08:00
|
|
|
|
return {
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
play,
|
|
|
|
|
|
isPlay,
|
|
|
|
|
|
playMusic,
|
|
|
|
|
|
playMusicUrl,
|
|
|
|
|
|
musicFull,
|
|
|
|
|
|
playbackRate,
|
|
|
|
|
|
volume,
|
2026-04-26 21:47:11 +08:00
|
|
|
|
isMuted,
|
2025-11-08 14:26:04 +08:00
|
|
|
|
userPlayIntent,
|
2026-03-22 16:49:00 +08:00
|
|
|
|
isFmPlaying,
|
2026-02-04 20:10:28 +08:00
|
|
|
|
audioOutputDeviceId,
|
|
|
|
|
|
availableAudioDevices,
|
2025-11-08 14:26:04 +08:00
|
|
|
|
|
|
|
|
|
|
// Computed
|
|
|
|
|
|
currentSong,
|
|
|
|
|
|
isPlaying,
|
|
|
|
|
|
|
|
|
|
|
|
// Actions
|
|
|
|
|
|
setIsPlay,
|
|
|
|
|
|
setMusicFull,
|
|
|
|
|
|
setPlayMusic,
|
|
|
|
|
|
setPlaybackRate,
|
|
|
|
|
|
setVolume,
|
|
|
|
|
|
getVolume,
|
|
|
|
|
|
increaseVolume,
|
|
|
|
|
|
decreaseVolume,
|
2026-04-26 21:47:11 +08:00
|
|
|
|
setMuted,
|
|
|
|
|
|
toggleMute,
|
2025-11-08 14:26:04 +08:00
|
|
|
|
handlePause,
|
2026-02-04 20:10:28 +08:00
|
|
|
|
refreshAudioDevices,
|
|
|
|
|
|
setAudioOutputDevice,
|
|
|
|
|
|
initAudioDeviceListener
|
2025-11-08 14:26:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
persist: {
|
|
|
|
|
|
key: 'player-core-store',
|
2026-05-17 23:07:58 +08:00
|
|
|
|
// 使用 debouncedLocalStorage:volume 拖动 / 静音切换会高频触发 mutation,
|
|
|
|
|
|
// 直接写 localStorage 会导致每次都 stringify + minify 整个 state,浪费 I/O。
|
|
|
|
|
|
// 防抖 2s 写一次足够,beforeunload 钩子兜底刷盘。
|
|
|
|
|
|
// Trade-off:极端非正常退出(kill -9 / 断电 / 主进程崩溃)下 beforeunload 不触发,
|
|
|
|
|
|
// 最近 2s 的 volume / isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
|
|
|
|
|
|
storage: debouncedLocalStorage,
|
2026-04-26 21:47:11 +08:00
|
|
|
|
pick: [
|
|
|
|
|
|
'playMusic',
|
|
|
|
|
|
'playMusicUrl',
|
|
|
|
|
|
'playbackRate',
|
|
|
|
|
|
'volume',
|
|
|
|
|
|
'isMuted',
|
|
|
|
|
|
'isPlay',
|
|
|
|
|
|
'audioOutputDeviceId'
|
2026-05-17 23:07:58 +08:00
|
|
|
|
],
|
|
|
|
|
|
// playMusic 持久化前过 minifySong:剥离 base64 封面、lyric、song 等大字段。
|
|
|
|
|
|
// 单首歌的 lyric 持久化后没有用(重启后会重新加载),但 picUrl 若是 base64 会
|
|
|
|
|
|
// 拖累整个 player-core-store 写入失败(5MB 配额)。playMusicUrl 在 store 层级单独
|
|
|
|
|
|
// 持久化,不受 playMusic 内部精简影响——本地音乐 local:// URL 仍保持可恢复。
|
|
|
|
|
|
// id 守卫:空 playMusic 走 minifySong 会得到 {picUrl:'', ar:[]}(stripDataUrl
|
|
|
|
|
|
// 把 undefined 转成空串、ar 缺省返回空数组),下次启动 playbackController 的
|
|
|
|
|
|
// Object.keys().length === 0 判空就会失效,误恢复一首无 id 的空歌
|
|
|
|
|
|
serializer: {
|
|
|
|
|
|
serialize: (state: any) =>
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
...state,
|
|
|
|
|
|
playMusic: state.playMusic?.id ? minifySong(state.playMusic as SongResult) : {}
|
|
|
|
|
|
}),
|
|
|
|
|
|
deserialize: JSON.parse
|
|
|
|
|
|
}
|
2025-11-08 14:26:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|