YesPlayMusic/src/renderer/utils/player.ts

457 lines
11 KiB
TypeScript

import { Howl, Howler } from 'howler'
import {
fetchAudioSourceWithReactQuery,
fetchTracksWithReactQuery,
} from '@/renderer/hooks/useTracks'
import { fetchPersonalFMWithReactQuery } from '@/renderer/hooks/usePersonalFM'
import { fmTrash } from '@/renderer/api/personalFM'
import { cacheAudio } from '@/renderer/api/yesplaymusic'
import { clamp } from 'lodash-es'
import axios from 'axios'
import { resizeImage } from './common'
import { fetchPlaylistWithReactQuery } from '@/renderer/hooks/usePlaylist'
import { fetchAlbumWithReactQuery } from '@/renderer/hooks/useAlbum'
type TrackID = number
export enum TrackListSourceType {
Album = 'album',
Playlist = 'playlist',
}
interface TrackListSource {
type: TrackListSourceType
id: number
}
export enum Mode {
TrackList = 'trackList',
FM = 'fm',
}
export enum State {
Initializing = 'initializing',
Ready = 'ready',
Playing = 'playing',
Paused = 'paused',
Loading = 'loading',
}
export enum RepeatMode {
Off = 'off',
On = 'on',
One = 'one',
}
const PLAY_PAUSE_FADE_DURATION = 200
let _howler = new Howl({ src: [''], format: ['mp3', 'flac'] })
export class Player {
private _track: Track | null = null
private _trackIndex: number = 0
private _progress: number = 0
private _progressInterval: ReturnType<typeof setInterval> | undefined
private _volume: number = 1 // 0 to 1
state: State = State.Initializing
mode: Mode = Mode.TrackList
trackList: TrackID[] = []
trackListSource: TrackListSource | null = null
fmTrackList: TrackID[] = []
shuffle: boolean = false
repeatMode: RepeatMode = RepeatMode.Off
fmTrack: Track | null = null
init(params: { [key: string]: any }) {
if (params._track) this._track = params._track
if (params._trackIndex) this._trackIndex = params._trackIndex
if (params._volume) this._volume = params._volume
if (params.state) this.trackList = params.state
if (params.mode) this.mode = params.mode
if (params.trackList) this.trackList = params.trackList
if (params.trackListSource) this.trackListSource = params.trackListSource
if (params.fmTrackList) this.fmTrackList = params.fmTrackList
if (params.shuffle) this.shuffle = params.shuffle
if (params.repeatMode) this.repeatMode = params.repeatMode
if (params.fmTrack) this.fmTrack = params.fmTrack
this.state = State.Ready
this._playAudio(false) // just load the audio, not play
this._initFM()
}
get howler() {
return _howler
}
/**
* Get prev track index
*/
get _prevTrackIndex(): number | undefined {
switch (this.repeatMode) {
case RepeatMode.One:
return this._trackIndex
case RepeatMode.Off:
if (this._trackIndex === 0) return 0
return this._trackIndex - 1
case RepeatMode.On:
if (this._trackIndex - 1 < 0) return this.trackList.length - 1
return this._trackIndex - 1
}
}
/**
* Get next track index
*/
get _nextTrackIndex(): number | undefined {
switch (this.repeatMode) {
case RepeatMode.One:
return this._trackIndex
case RepeatMode.Off:
if (this._trackIndex + 1 >= this.trackList.length) return
return this._trackIndex + 1
case RepeatMode.On:
if (this._trackIndex + 1 >= this.trackList.length) return 0
return this._trackIndex + 1
}
}
/**
* Get current playing track ID
*/
get trackID(): TrackID {
if (this.mode === Mode.TrackList) {
const { trackList, _trackIndex } = this
return trackList[_trackIndex] ?? 0
}
return this.fmTrackList[0] ?? 0
}
/**
* Get current playing track
*/
get track(): Track | null {
return this.mode === Mode.FM ? this.fmTrack : this._track
}
/**
* Get/Set progress of current track
*/
get progress(): number {
return this.state === State.Loading ? 0 : this._progress
}
set progress(value) {
this._progress = value
_howler.seek(value)
}
/**
* Get/Set current volume
*/
get volume(): number {
return this._volume
}
set volume(value) {
this._volume = clamp(value, 0, 1)
Howler.volume(this._volume)
}
private async _initFM() {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
const trackId = this.fmTrackList[0]
const track = await this._fetchTrack(trackId)
if (track) this.fmTrack = track
this._loadMoreFMTracks()
}
private _setupProgressInterval() {
this._progressInterval = setInterval(() => {
if (this.state === State.Playing) this._progress = _howler.seek()
}, 1000)
}
/**
* Fetch track details from Netease based on this.trackID
*/
private async _fetchTrack(trackID: TrackID) {
const response = await fetchTracksWithReactQuery({ ids: [trackID] })
return response?.songs?.length ? response.songs[0] : null
}
/**
* Fetch track audio source url from Netease
* @param {TrackID} trackID
*/
private async _fetchAudioSource(trackID: TrackID) {
const response = await fetchAudioSourceWithReactQuery({ id: trackID })
return {
audio: response.data?.[0]?.url,
id: trackID,
}
}
/**
* Play a track based on this.trackID
*/
private async _playTrack() {
const id = this.trackID
if (!id) return
this.state = State.Loading
const track = await this._fetchTrack(id)
if (!track) {
toast('加载歌曲信息失败')
return
}
if (this.mode === Mode.TrackList) this._track = track
if (this.mode === Mode.FM) this.fmTrack = track
this._playAudio()
}
/**
* Play audio via howler
*/
private async _playAudio(autoplay: boolean = true) {
this._progress = 0
const { audio, id } = await this._fetchAudioSource(this.trackID)
if (!audio) {
toast('无法播放此歌曲')
this.nextTrack()
return
}
if (this.trackID !== id) return
Howler.unload()
const howler = new Howl({
src: [`${audio}?id=${id}`],
format: ['mp3', 'flac'],
html5: true,
autoplay,
volume: 1,
onend: () => this._howlerOnEndCallback(),
})
_howler = howler
if (autoplay) {
this.play()
this.state = State.Playing
}
_howler.once('load', () => {
this._cacheAudio((_howler as any)._src)
})
if (!this._progressInterval) {
this._setupProgressInterval()
}
}
private _howlerOnEndCallback() {
if (this.mode !== Mode.FM && this.repeatMode === RepeatMode.One) {
_howler.seek(0)
_howler.play()
} else {
this.nextTrack()
}
}
private _cacheAudio(audio: string) {
if (audio.includes('yesplaymusic')) return
const id = Number(audio.split('?id=')[1])
if (isNaN(id) || !id) return
cacheAudio(id, audio)
}
private async _nextFMTrack() {
const prefetchNextTrack = async () => {
const prefetchTrackID = this.fmTrackList[1]
const track = await this._fetchTrack(prefetchTrackID)
if (track?.al.picUrl) {
axios.get(resizeImage(track.al.picUrl, 'md'))
axios.get(resizeImage(track.al.picUrl, 'xs'))
}
}
this.fmTrackList.shift()
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
this._playTrack()
this.fmTrackList.length <= 1
? await this._loadMoreFMTracks()
: this._loadMoreFMTracks()
prefetchNextTrack()
}
private async _loadMoreFMTracks() {
if (this.fmTrackList.length <= 5) {
const response = await fetchPersonalFMWithReactQuery()
this.fmTrackList.push(...(response?.data?.map(r => r.id) ?? []))
}
}
/**
* Play current track
* @param {boolean} fade fade in
*/
play(fade: boolean = false) {
if (_howler.playing()) {
this.state = State.Playing
return
}
_howler.play()
if (fade) {
this.state = State.Playing
_howler.once('play', () => {
_howler.fade(0, this._volume, PLAY_PAUSE_FADE_DURATION)
})
} else {
this.state = State.Playing
}
}
/**
* Pause current track
* @param {boolean} fade fade out
*/
pause(fade: boolean = false) {
if (fade) {
_howler.fade(this._volume, 0, PLAY_PAUSE_FADE_DURATION)
this.state = State.Paused
_howler.once('fade', () => {
_howler.pause()
})
} else {
this.state = State.Paused
_howler.pause()
}
}
/**
* Play or pause current track
* @param {boolean} fade fade in-out
*/
playOrPause(fade: boolean = true) {
this.state === State.Playing ? this.pause(fade) : this.play(fade)
}
/**
* Play previous track
*/
prevTrack() {
this._progress = 0
if (this.mode === Mode.FM) {
toast('Personal FM not support previous track')
return
}
if (this._prevTrackIndex === undefined) {
toast('No previous track')
return
}
this._trackIndex = this._prevTrackIndex
this._playTrack()
}
/**
* Play next track
*/
nextTrack(forceFM: boolean = false) {
this._progress = 0
if (forceFM || this.mode === Mode.FM) {
this.mode = Mode.FM
this._nextFMTrack()
return
}
if (this._nextTrackIndex === undefined) {
toast('没有下一首了')
this.pause()
return
}
this._trackIndex = this._nextTrackIndex
this._playTrack()
}
/**
* 播放一个track id列表
* @param {number[]} list
* @param {null|number} autoPlayTrackID
*/
playAList(list: TrackID[], autoPlayTrackID?: null | number) {
this.mode = Mode.TrackList
this.trackList = list
this._trackIndex = autoPlayTrackID
? list.findIndex(t => t === autoPlayTrackID)
: 0
this._playTrack()
}
/**
* Play a playlist
* @param {number} playlistID
* @param {null|number=} autoPlayTrackID
*/
async playPlaylist(playlistID: number, autoPlayTrackID?: null | number) {
const playlist = await fetchPlaylistWithReactQuery({ id: playlistID })
if (!playlist?.playlist?.trackIds?.length) return
this.trackListSource = {
type: TrackListSourceType.Playlist,
id: playlistID,
}
this.playAList(
playlist.playlist.trackIds.map(t => t.id),
autoPlayTrackID
)
}
/**
* Play am album
* @param {number} albumID
* @param {null|number=} autoPlayTrackID
*/
async playAlbum(albumID: number, autoPlayTrackID?: null | number) {
const album = await fetchAlbumWithReactQuery({ id: albumID })
if (!album?.songs?.length) return
this.trackListSource = {
type: TrackListSourceType.Album,
id: albumID,
}
this._playTrack()
this.playAList(
album.songs.map(t => t.id),
autoPlayTrackID
)
}
/**
* Play personal fm
*/
async playFM() {
this.mode = Mode.FM
if (
this.fmTrackList.length > 0 &&
this.fmTrack?.id === this.fmTrackList[0]
) {
this._track = this.fmTrack
this._playAudio()
} else {
this._playTrack()
}
}
/**
* Trash current PersonalFMTrack
*/
async fmTrash() {
this.mode = Mode.FM
const trashTrackID = this.fmTrackList[0]
fmTrash(trashTrackID)
this._nextFMTrack()
}
/**
* Play track in trackList by id
*/
async playTrack(trackID: TrackID) {
const index = this.trackList.findIndex(t => t === trackID)
if (!index) toast('播放失败,歌曲不在列表内')
this._trackIndex = index
this._playTrack()
}
}
if (import.meta.env.DEV) {
;(window as any).howler = _howler
}