2026-02-03 12:59:04 +08:00
|
|
|
|
import { defineStore } from 'pinia';
|
|
|
|
|
|
import { ref } from 'vue';
|
|
|
|
|
|
import { MessagePlugin } from 'tdesign-vue-next';
|
|
|
|
|
|
import type { Song } from '../types/song';
|
|
|
|
|
|
|
|
|
|
|
|
export enum PlayMode {
|
|
|
|
|
|
List = 'list',
|
|
|
|
|
|
Single = 'single',
|
|
|
|
|
|
Random = 'random'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 静音 WAV Base64
|
|
|
|
|
|
const SILENT_AUDIO_URL = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
|
|
|
|
|
|
|
|
|
|
|
|
const dummyAudio = new Audio();
|
|
|
|
|
|
dummyAudio.src = SILENT_AUDIO_URL;
|
|
|
|
|
|
dummyAudio.loop = true;
|
|
|
|
|
|
dummyAudio.volume = 0.01;
|
|
|
|
|
|
if (typeof document !== 'undefined') {
|
|
|
|
|
|
document.body.appendChild(dummyAudio);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const usePlayerStore = defineStore('player', () => {
|
|
|
|
|
|
// State
|
|
|
|
|
|
const isPlaying = ref(false);
|
|
|
|
|
|
const currentSong = ref<Song | null>(null);
|
2026-02-05 23:44:51 +08:00
|
|
|
|
const volume = ref(50);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
const duration = ref(0);
|
|
|
|
|
|
const currentTime = ref(0);
|
|
|
|
|
|
|
2026-02-05 18:52:58 +08:00
|
|
|
|
// Audio Visualization State
|
|
|
|
|
|
const loudness = ref(0);
|
|
|
|
|
|
const spectrum = ref<number[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
// UI State
|
|
|
|
|
|
const isPlayerFullScreen = ref(false);
|
|
|
|
|
|
|
2026-02-03 12:59:04 +08:00
|
|
|
|
// Playlist State
|
|
|
|
|
|
const playlist = ref<Song[]>([]);
|
|
|
|
|
|
const currentIndex = ref(-1);
|
|
|
|
|
|
const playMode = ref<PlayMode>(PlayMode.List);
|
|
|
|
|
|
|
|
|
|
|
|
// Error Handling
|
|
|
|
|
|
const playErrorCount = ref(0);
|
|
|
|
|
|
const MAX_RETRY_COUNT = 3;
|
2026-02-04 10:30:28 +08:00
|
|
|
|
const hasRetriedWithFreshUrl = ref(false);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
|
|
|
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
|
|
const activateDummyAudio = async () => {
|
|
|
|
|
|
if (dummyAudio.paused) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await dummyAudio.play().catch(e => console.warn('Dummy play failed:', e));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// Ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('mediaSession' in navigator) {
|
|
|
|
|
|
navigator.mediaSession.playbackState = 'playing';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 同步浏览器状态
|
|
|
|
|
|
const syncDummyAudioState = (shouldPlay: boolean) => {
|
|
|
|
|
|
if (shouldPlay) {
|
|
|
|
|
|
if (dummyAudio.paused) {
|
|
|
|
|
|
dummyAudio.play().catch(() => { });
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (!dummyAudio.paused) {
|
|
|
|
|
|
dummyAudio.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- Actions ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const setPlaylist = async (list: any[], startIndex = 0) => {
|
|
|
|
|
|
playlist.value = list;
|
|
|
|
|
|
currentIndex.value = startIndex;
|
|
|
|
|
|
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
|
|
|
|
|
await playSong(list[startIndex]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const playSong = async (song: Song) => {
|
|
|
|
|
|
if (!song) return;
|
|
|
|
|
|
|
|
|
|
|
|
currentSong.value = song;
|
|
|
|
|
|
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
|
|
|
|
|
if (foundIndex !== -1) {
|
|
|
|
|
|
currentIndex.value = foundIndex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await activateDummyAudio();
|
|
|
|
|
|
updateMediaSession(song);
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Get URL (Cache -> Network)
|
|
|
|
|
|
let playUrl = song.url;
|
|
|
|
|
|
if (song.type === 'Remote' && song.source) {
|
2026-02-04 10:30:28 +08:00
|
|
|
|
// Use Local Proxy
|
2026-02-05 18:52:58 +08:00
|
|
|
|
const quality = 'jymaster';
|
2026-02-04 10:30:28 +08:00
|
|
|
|
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
|
|
|
|
|
|
console.log('[Player] Using Proxy:', playUrl);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playUrl) {
|
|
|
|
|
|
console.log('Playing:', song.name);
|
2026-02-04 10:30:28 +08:00
|
|
|
|
// Reset retry flag for new playback attempt
|
|
|
|
|
|
hasRetriedWithFreshUrl.value = false;
|
2026-02-03 12:59:04 +08:00
|
|
|
|
try {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
await window.electronAPI.qzplayer.load(playUrl);
|
|
|
|
|
|
await window.electronAPI.qzplayer.play();
|
2026-02-03 12:59:04 +08:00
|
|
|
|
isPlaying.value = true;
|
|
|
|
|
|
song.url = playUrl;
|
|
|
|
|
|
} catch (e) {
|
2026-02-04 10:30:28 +08:00
|
|
|
|
// IPC call failed (rare), handle sync error
|
|
|
|
|
|
console.error("IPC Play request failed:", e);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
handlePlayError();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn("Song has no URL");
|
|
|
|
|
|
handlePlayError();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateMediaSession = (song: Song) => {
|
|
|
|
|
|
if (!('mediaSession' in navigator)) return;
|
|
|
|
|
|
|
|
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
|
|
|
|
title: song.name,
|
|
|
|
|
|
artist: song.artist,
|
|
|
|
|
|
album: song.albumName || '',
|
|
|
|
|
|
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-05 18:52:58 +08:00
|
|
|
|
navigator.mediaSession.setActionHandler('play', () => window.electronAPI.qzplayer.play());
|
|
|
|
|
|
navigator.mediaSession.setActionHandler('pause', () => window.electronAPI.qzplayer.pause());
|
2026-02-03 12:59:04 +08:00
|
|
|
|
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
|
|
|
|
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
|
|
|
|
|
|
|
|
|
|
|
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
|
|
|
|
if (details.seekTime != null) {
|
|
|
|
|
|
seek(details.seekTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const next = async (manual = true) => {
|
|
|
|
|
|
if (playlist.value.length === 0) return;
|
|
|
|
|
|
let nextIndex = currentIndex.value;
|
|
|
|
|
|
if (playMode.value === PlayMode.Single && !manual) {
|
|
|
|
|
|
nextIndex = currentIndex.value;
|
|
|
|
|
|
} else if (playMode.value === PlayMode.Random) {
|
|
|
|
|
|
nextIndex = Math.floor(Math.random() * playlist.value.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextIndex = (currentIndex.value + 1) % playlist.value.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentIndex.value = nextIndex;
|
|
|
|
|
|
await playSong(playlist.value[nextIndex]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const prev = async () => {
|
|
|
|
|
|
if (playlist.value.length === 0) return;
|
|
|
|
|
|
let prevIndex = currentIndex.value;
|
|
|
|
|
|
if (playMode.value === PlayMode.Random) {
|
|
|
|
|
|
prevIndex = Math.floor(Math.random() * playlist.value.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentIndex.value = prevIndex;
|
|
|
|
|
|
await playSong(playlist.value[prevIndex]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-04 10:30:28 +08:00
|
|
|
|
const handlePlayError = async () => {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
// Proxy handles refreshing internally, so we rely on qzplayer error/retry for now.
|
2026-02-04 10:30:28 +08:00
|
|
|
|
// Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work).
|
|
|
|
|
|
|
|
|
|
|
|
// Normal error handling
|
2026-02-03 12:59:04 +08:00
|
|
|
|
playErrorCount.value++;
|
2026-02-04 10:30:28 +08:00
|
|
|
|
hasRetriedWithFreshUrl.value = false; // Reset for next song
|
|
|
|
|
|
|
2026-02-03 12:59:04 +08:00
|
|
|
|
if (playlist.value.length === 0) {
|
|
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
|
playErrorCount.value = 0;
|
|
|
|
|
|
syncDummyAudioState(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
window.electronAPI.qzplayer.pause();
|
2026-02-03 12:59:04 +08:00
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
|
MessagePlugin.error('连续多次播放失败,已停止播放');
|
|
|
|
|
|
playErrorCount.value = 0;
|
|
|
|
|
|
syncDummyAudioState(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`);
|
2026-02-04 10:30:28 +08:00
|
|
|
|
setTimeout(() => next(false), 500);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Listeners
|
|
|
|
|
|
if (window.electronAPI) {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
window.electronAPI.qzplayer.onEvent((_event, data) => {
|
2026-02-03 12:59:04 +08:00
|
|
|
|
if (data.event === 'property-change') {
|
|
|
|
|
|
if (data.name === 'pause') {
|
|
|
|
|
|
const isPaused = data.data;
|
|
|
|
|
|
isPlaying.value = !isPaused;
|
2026-02-05 18:52:58 +08:00
|
|
|
|
// 核心:qzplayer 暂停 -> 同步暂停 Dummy -> 浏览器更新 SMTC 状态
|
2026-02-03 12:59:04 +08:00
|
|
|
|
syncDummyAudioState(!isPaused);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.name === 'time-pos') currentTime.value = data.data;
|
|
|
|
|
|
if (data.name === 'duration') duration.value = data.data;
|
2026-02-05 18:52:58 +08:00
|
|
|
|
if (data.name === 'loudness') loudness.value = data.data;
|
|
|
|
|
|
if (data.name === 'spectrum') spectrum.value = data.data;
|
2026-02-03 12:59:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.event === 'end-file') {
|
|
|
|
|
|
const reason = data.reason;
|
|
|
|
|
|
if (reason === 'eof') {
|
|
|
|
|
|
next(false);
|
|
|
|
|
|
} else if (reason === 'error') {
|
|
|
|
|
|
handlePlayError();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const togglePlay = async () => {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
await window.electronAPI.qzplayer.togglePause();
|
2026-02-03 12:59:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setVolume = async (vol: number) => {
|
|
|
|
|
|
volume.value = vol;
|
2026-02-05 18:52:58 +08:00
|
|
|
|
await window.electronAPI.qzplayer.setVolume(vol);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const seek = async (time: number) => {
|
2026-02-05 18:52:58 +08:00
|
|
|
|
await window.electronAPI.qzplayer.seek(time);
|
2026-02-03 12:59:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleMode = () => {
|
|
|
|
|
|
if (playMode.value === PlayMode.List) playMode.value = PlayMode.Single;
|
|
|
|
|
|
else if (playMode.value === PlayMode.Single) playMode.value = PlayMode.Random;
|
|
|
|
|
|
else playMode.value = PlayMode.List;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-05 18:52:58 +08:00
|
|
|
|
const toggleFullScreen = () => {
|
|
|
|
|
|
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-03 12:59:04 +08:00
|
|
|
|
return {
|
|
|
|
|
|
isPlaying,
|
|
|
|
|
|
currentSong,
|
|
|
|
|
|
volume,
|
|
|
|
|
|
duration,
|
|
|
|
|
|
currentTime,
|
|
|
|
|
|
playlist,
|
|
|
|
|
|
playMode,
|
2026-02-05 18:52:58 +08:00
|
|
|
|
loudness,
|
|
|
|
|
|
spectrum,
|
|
|
|
|
|
isPlayerFullScreen,
|
2026-02-03 12:59:04 +08:00
|
|
|
|
setPlaylist,
|
|
|
|
|
|
playSong,
|
|
|
|
|
|
next,
|
|
|
|
|
|
prev,
|
|
|
|
|
|
togglePlay,
|
|
|
|
|
|
setVolume,
|
|
|
|
|
|
seek,
|
2026-02-05 18:52:58 +08:00
|
|
|
|
toggleMode,
|
|
|
|
|
|
toggleFullScreen
|
2026-02-03 12:59:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
});
|