Initial commit: QZMusic Web version
This commit is contained in:
376
src/stores/player.ts
Normal file
376
src/stores/player.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import type { Song } from '../types/song';
|
||||
import { parseLyric } from '../utils/lyricUtil'
|
||||
|
||||
export enum PlayMode {
|
||||
List = 'list',
|
||||
Single = 'single',
|
||||
Random = 'random'
|
||||
}
|
||||
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
let audioContext: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
// State
|
||||
const isPlaying = ref(false);
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const savedVolume = localStorage.getItem('qz-player-volume');
|
||||
const volume = ref(savedVolume ? Number(savedVolume) : 50); // 1~100
|
||||
|
||||
// Persistence & Sync
|
||||
watch(volume, (newVol) => {
|
||||
localStorage.setItem('qz-player-volume', newVol.toString());
|
||||
if (audioElement) {
|
||||
audioElement.volume = newVol / 100;
|
||||
}
|
||||
});
|
||||
|
||||
const duration = ref(0); //毫秒级
|
||||
const currentTime = ref(0); //毫秒级
|
||||
|
||||
// Audio Visualization State
|
||||
const loudness = ref(0);
|
||||
const spectrum = ref<number[]>([]);
|
||||
|
||||
// UI State
|
||||
const isPlayerFullScreen = ref(false);
|
||||
const hideLyricView = ref(false);
|
||||
|
||||
// Playlist State
|
||||
const savedPlaylist = localStorage.getItem('qz-player-playlist');
|
||||
const savedIndex = localStorage.getItem('qz-player-index');
|
||||
|
||||
const playlist = ref<Song[]>(savedPlaylist ? JSON.parse(savedPlaylist) : []);
|
||||
const currentIndex = ref(savedIndex ? Number(savedIndex) : -1);
|
||||
const playMode = ref<PlayMode>(PlayMode.List);
|
||||
const savedAddMode = localStorage.getItem('qz-player-add-mode');
|
||||
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
||||
|
||||
// Error Handling
|
||||
const playErrorCount = ref(0);
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
|
||||
// Lyrics State
|
||||
const lyrics = shallowRef<{ lines: any[] }>({ lines: [] });
|
||||
|
||||
// Initialize audio element
|
||||
const initAudio = () => {
|
||||
if (!audioElement && typeof window !== 'undefined') {
|
||||
audioElement = new Audio();
|
||||
audioElement.volume = volume.value / 100;
|
||||
|
||||
// Setup audio context for visualization
|
||||
if (window.AudioContext || (window as any).webkitAudioContext) {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
const source = audioContext.createMediaElementSource(audioElement);
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
audioElement.addEventListener('loadedmetadata', () => {
|
||||
if (audioElement) {
|
||||
duration.value = audioElement.duration * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('timeupdate', () => {
|
||||
if (audioElement) {
|
||||
currentTime.value = audioElement.currentTime * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', () => {
|
||||
isPlaying.value = true;
|
||||
startVisualization();
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('pause', () => {
|
||||
isPlaying.value = false;
|
||||
stopVisualization();
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('ended', () => {
|
||||
next(false);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', (e) => {
|
||||
console.error('Audio error:', e);
|
||||
handlePlayError();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startVisualization = () => {
|
||||
if (!analyser || !audioContext) return;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const animate = () => {
|
||||
if (!isPlaying.value) return;
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
analyser?.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average loudness
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
loudness.value = sum / bufferLength;
|
||||
|
||||
// Update spectrum
|
||||
spectrum.value = Array.from(dataArray);
|
||||
};
|
||||
animate();
|
||||
};
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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 playFromList = async (song: Song, contextList: Song[]) => {
|
||||
if (addListMode.value === 'replace') {
|
||||
const index = contextList.findIndex(s => s.id === song.id);
|
||||
if (index !== -1) {
|
||||
await setPlaylist(contextList, index);
|
||||
} else {
|
||||
await setPlaylist([song], 0);
|
||||
}
|
||||
} else {
|
||||
playlist.value.push(song);
|
||||
const newIndex = playlist.value.length - 1;
|
||||
currentIndex.value = newIndex;
|
||||
await playSong(song);
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = async (song: Song, autoPlay = true) => {
|
||||
if (!song) return;
|
||||
initAudio();
|
||||
|
||||
console.log(song);
|
||||
currentSong.value = song;
|
||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||
if (foundIndex !== -1) {
|
||||
currentIndex.value = foundIndex;
|
||||
}
|
||||
|
||||
updateMediaSession(song);
|
||||
fetchLyrics(song);
|
||||
|
||||
// Resume audio context if suspended (for autoplay policy)
|
||||
if (audioContext && audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
let playUrl = song.url;
|
||||
if (song.type === 'Remote' && song.source) {
|
||||
const quality = 'hires';
|
||||
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
|
||||
console.log('[Player] Using Proxy:', playUrl);
|
||||
}
|
||||
|
||||
if (playUrl && audioElement) {
|
||||
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
||||
try {
|
||||
audioElement.src = playUrl;
|
||||
audioElement.load();
|
||||
if (autoPlay) {
|
||||
await audioElement.play();
|
||||
}
|
||||
song.url = playUrl;
|
||||
playErrorCount.value = 0;
|
||||
} catch (e) {
|
||||
console.error("Play request failed:", e);
|
||||
if (autoPlay) handlePlayError();
|
||||
}
|
||||
} else {
|
||||
console.warn("Song has no URL");
|
||||
if (autoPlay) handlePlayError();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLyrics = async (song: Song) => {
|
||||
lyrics.value = { lines: [] };
|
||||
if (!song || !song.id) return;
|
||||
try {
|
||||
// TODO: Implement web-based lyric fetching
|
||||
MessagePlugin.info("网页版暂不支持歌词获取").then();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch lyrics:', e);
|
||||
}
|
||||
};
|
||||
|
||||
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' }] : []
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => togglePlay());
|
||||
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
|
||||
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]);
|
||||
};
|
||||
|
||||
const handlePlayError = async () => {
|
||||
playErrorCount.value++;
|
||||
if (playlist.value.length === 0) {
|
||||
isPlaying.value = false;
|
||||
playErrorCount.value = 0;
|
||||
return;
|
||||
}
|
||||
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
||||
audioElement?.pause();
|
||||
isPlaying.value = false;
|
||||
MessagePlugin.error('连续多次播放失败,已停止播放').then();
|
||||
playErrorCount.value = 0;
|
||||
} else {
|
||||
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`).then();
|
||||
next(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (!audioElement) {
|
||||
initAudio();
|
||||
}
|
||||
if (audioElement) {
|
||||
if (isPlaying.value) {
|
||||
audioElement.pause();
|
||||
} else {
|
||||
await audioElement.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setVolume = async (vol: number) => {
|
||||
volume.value = vol;
|
||||
if (audioElement) {
|
||||
audioElement.volume = vol / 100;
|
||||
}
|
||||
};
|
||||
|
||||
const seek = async (time: number) => {
|
||||
if (audioElement) {
|
||||
audioElement.currentTime = time / 1000;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
||||
};
|
||||
|
||||
// Persistence Listeners
|
||||
watch(() => [playlist.value, currentIndex.value], () => {
|
||||
localStorage.setItem('qz-player-playlist', JSON.stringify(playlist.value));
|
||||
localStorage.setItem('qz-player-index', currentIndex.value.toString());
|
||||
}, { deep: true });
|
||||
|
||||
watch(addListMode, (newMode) => {
|
||||
localStorage.setItem('qz-player-add-mode', newMode);
|
||||
});
|
||||
|
||||
// Restore initial state
|
||||
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
|
||||
const restoredSong = playlist.value[currentIndex.value];
|
||||
if (restoredSong) {
|
||||
// Do not auto-play on restore
|
||||
currentSong.value = restoredSong;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
volume,
|
||||
duration,
|
||||
currentTime,
|
||||
playlist,
|
||||
playMode,
|
||||
loudness,
|
||||
spectrum,
|
||||
isPlayerFullScreen,
|
||||
setPlaylist,
|
||||
playSong,
|
||||
next,
|
||||
prev,
|
||||
togglePlay,
|
||||
setVolume,
|
||||
seek,
|
||||
toggleMode,
|
||||
toggleFullScreen,
|
||||
lyrics,
|
||||
fetchLyrics,
|
||||
addListMode,
|
||||
playFromList,
|
||||
hideLyricView
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user