mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 05:38:04 +00:00
feat: monorepo
This commit is contained in:
parent
4d54060a4f
commit
42089d4996
200 changed files with 1530 additions and 1521 deletions
160
packages/web/utils/common.ts
Normal file
160
packages/web/utils/common.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { average } from 'color.js'
|
||||
import { colord } from 'colord'
|
||||
|
||||
/**
|
||||
* @description 调整网易云封面图片大小
|
||||
* @param {string} url 封面图片URL
|
||||
* @param {'xs'|'sm'|'md'|'lg'} size - 大小,值对应为 128px | 256px | 512px | 1024px
|
||||
*/
|
||||
export function resizeImage(
|
||||
url: string,
|
||||
size: 'xs' | 'sm' | 'md' | 'lg'
|
||||
): string {
|
||||
if (!url) return ''
|
||||
|
||||
const sizeMap = {
|
||||
xs: '128',
|
||||
sm: '256',
|
||||
md: '512',
|
||||
lg: '1024',
|
||||
}
|
||||
return `${url}?param=${sizeMap[size]}y${sizeMap[size]}`.replace(
|
||||
'http://',
|
||||
'https://'
|
||||
)
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get(key: string): object | [] | null {
|
||||
const text = localStorage.getItem(key)
|
||||
return text ? JSON.parse(text) : null
|
||||
},
|
||||
set(key: string, value: object | []): void {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 格式化日期
|
||||
* @param {number} timestamp - 时间戳
|
||||
* @param {'en'|'zh-TW'|'zh-CN'='en'} locale - 日期语言
|
||||
* @param {string='default'} format - 格式化字符串,参考 dayjs
|
||||
*/
|
||||
export function formatDate(
|
||||
timestamp: number,
|
||||
locale: 'en' | 'zh-TW' | 'zh-CN' = 'zh-CN',
|
||||
format: string = 'default'
|
||||
): string {
|
||||
if (!timestamp) return ''
|
||||
if (format === 'default') {
|
||||
format = 'MMM D, YYYY'
|
||||
if (['zh-CN', 'zh-TW'].includes(locale)) format = 'YYYY年MM月DD日'
|
||||
}
|
||||
return dayjs(timestamp).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 格式化时长
|
||||
* @param {number} milliseconds - 毫秒数
|
||||
* @param {'en'|'zh-TW'|'zh-CN'='en'} locale - 语言
|
||||
* @param {'hh:mm:ss'|'hh[hr]mm[min]'='hh:mm:ss'} format - 格式化字符串
|
||||
*/
|
||||
export function formatDuration(
|
||||
milliseconds: number,
|
||||
locale: 'en' | 'zh-TW' | 'zh-CN' = 'zh-CN',
|
||||
format: 'hh:mm:ss' | 'hh[hr] mm[min]' = 'hh:mm:ss'
|
||||
): string {
|
||||
dayjs.extend(duration)
|
||||
|
||||
const time = dayjs.duration(milliseconds)
|
||||
const hours = time.hours().toString()
|
||||
const mins = time.minutes().toString()
|
||||
const seconds = time.seconds().toString().padStart(2, '0')
|
||||
|
||||
if (format === 'hh:mm:ss') {
|
||||
return hours !== '0'
|
||||
? `${hours}:${mins.padStart(2, '0')}:${seconds}`
|
||||
: `${mins}:${seconds}`
|
||||
} else {
|
||||
const units = {
|
||||
en: {
|
||||
hours: 'hr',
|
||||
mins: 'min',
|
||||
},
|
||||
'zh-CN': {
|
||||
hours: '小时',
|
||||
mins: '分钟',
|
||||
},
|
||||
'zh-TW': {
|
||||
hours: '小時',
|
||||
mins: '分鐘',
|
||||
},
|
||||
}
|
||||
|
||||
return hours !== '0'
|
||||
? `${hours} ${units[locale].hours}${
|
||||
mins === '0' ? '' : ` ${mins} ${units[locale].mins}`
|
||||
}`
|
||||
: `${mins} ${units[locale].mins}`
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToTop(smooth = false) {
|
||||
const main = document.getElementById('mainContainer')
|
||||
if (!main) return
|
||||
main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
|
||||
}
|
||||
|
||||
export async function getCoverColor(coverUrl: string) {
|
||||
let id: string | undefined
|
||||
try {
|
||||
id = new URL(coverUrl).pathname.split('/').pop()?.split('.')[0]
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
const colorFromCache = window.ipcRenderer?.sendSync(
|
||||
IpcChannels.GetApiCacheSync,
|
||||
{
|
||||
api: APIs.CoverColor,
|
||||
query: {
|
||||
id,
|
||||
},
|
||||
}
|
||||
) as string | undefined
|
||||
return colorFromCache || calcCoverColor(coverUrl)
|
||||
}
|
||||
|
||||
export async function cacheCoverColor(coverUrl: string, color: string) {
|
||||
let id: string | undefined
|
||||
try {
|
||||
id = new URL(coverUrl).pathname.split('/').pop()?.split('.')[0]
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!id || isNaN(Number(id))) return
|
||||
|
||||
window.ipcRenderer?.send(IpcChannels.CacheCoverColor, {
|
||||
id: Number(id),
|
||||
color,
|
||||
})
|
||||
}
|
||||
|
||||
export async function calcCoverColor(coverUrl: string) {
|
||||
if (!coverUrl) return
|
||||
const cover = resizeImage(coverUrl, 'xs')
|
||||
return average(cover, { amount: 1, format: 'hex', sample: 1 }).then(color => {
|
||||
let c = colord(color as string)
|
||||
const hsl = c.toHsl()
|
||||
if (hsl.s > 50) c = colord({ ...hsl, s: 50 })
|
||||
if (hsl.l > 50) c = colord({ ...c.toHsl(), l: 50 })
|
||||
if (hsl.l < 30) c = colord({ ...c.toHsl(), l: 30 })
|
||||
cacheCoverColor(coverUrl, c.toHex())
|
||||
return c.toHex()
|
||||
})
|
||||
}
|
||||
80
packages/web/utils/cookie.ts
Normal file
80
packages/web/utils/cookie.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import Cookies, { CookieAttributes } from 'js-cookie'
|
||||
|
||||
interface Cookie {
|
||||
key: string
|
||||
value: string
|
||||
options?: CookieAttributes
|
||||
}
|
||||
|
||||
export function parseCookies(cookie: string): Cookie[] {
|
||||
const cookies: Cookie[] = []
|
||||
let tmpCookie: Cookie | null = null
|
||||
cookie.split(';').forEach(item => {
|
||||
const splittedItem = item.split('=')
|
||||
if (splittedItem.length !== 2) return
|
||||
|
||||
const key = splittedItem[0].trim()
|
||||
const value = splittedItem[1].trim()
|
||||
|
||||
if (key.toLowerCase() === 'expires') return
|
||||
if (key.toLowerCase() === 'max-age') {
|
||||
const expires = Number(value) / 60 / 60 / 24
|
||||
if (isNaN(expires)) return
|
||||
tmpCookie = {
|
||||
...((tmpCookie as Cookie) ?? {}),
|
||||
options: {
|
||||
...tmpCookie?.options,
|
||||
expires: expires,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.toLowerCase() === 'path') {
|
||||
tmpCookie = {
|
||||
...((tmpCookie as Cookie) ?? {}),
|
||||
options: {
|
||||
...tmpCookie?.options,
|
||||
path: value,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (tmpCookie) cookies.push(tmpCookie)
|
||||
|
||||
tmpCookie = {
|
||||
key,
|
||||
value,
|
||||
}
|
||||
})
|
||||
|
||||
if (tmpCookie) cookies.push(tmpCookie)
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
export function setCookies(string: string) {
|
||||
const cookies = parseCookies(string)
|
||||
cookies.forEach(cookie => {
|
||||
Cookies.set(cookie.key, cookie.value, cookie.options)
|
||||
})
|
||||
}
|
||||
|
||||
export function getCookie(key: string) {
|
||||
return Cookies.get(key)
|
||||
}
|
||||
|
||||
export function removeCookie(key: string) {
|
||||
Cookies.remove(key)
|
||||
}
|
||||
|
||||
export function removeAllCookies() {
|
||||
const cookies = document.cookie.split(';')
|
||||
|
||||
cookies.forEach(cookie => {
|
||||
const splitted = cookie.split('=')
|
||||
const name = splitted[0].trim()
|
||||
document.cookie = `${name}=;max-age=0`
|
||||
})
|
||||
}
|
||||
13
packages/web/utils/initLog.ts
Normal file
13
packages/web/utils/initLog.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export {}
|
||||
|
||||
if (window.log) {
|
||||
Object.assign(console, window.log.functions)
|
||||
window.log.info(
|
||||
`\n\n██╗ ██╗███████╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗███╗ ███╗██╗ ██╗███████╗██╗ ██████╗
|
||||
╚██╗ ██╔╝██╔════╝██╔════╝██╔══██╗██║ ██╔══██╗╚██╗ ██╔╝████╗ ████║██║ ██║██╔════╝██║██╔════╝
|
||||
╚████╔╝ █████╗ ███████╗██████╔╝██║ ███████║ ╚████╔╝ ██╔████╔██║██║ ██║███████╗██║██║
|
||||
╚██╔╝ ██╔══╝ ╚════██║██╔═══╝ ██║ ██╔══██║ ╚██╔╝ ██║╚██╔╝██║██║ ██║╚════██║██║██║
|
||||
██║ ███████╗███████║██║ ███████╗██║ ██║ ██║ ██║ ╚═╝ ██║╚██████╔╝███████║██║╚██████╗
|
||||
╚═╝ ╚══════╝╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝\n`
|
||||
)
|
||||
}
|
||||
88
packages/web/utils/lyric.ts
Normal file
88
packages/web/utils/lyric.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { FetchLyricResponse } from '@/shared/api/Track'
|
||||
|
||||
export function lyricParser(lrc: FetchLyricResponse) {
|
||||
return {
|
||||
lyric: parseLyric(lrc?.lrc?.lyric || ''),
|
||||
tlyric: parseLyric(lrc?.tlyric?.lyric || ''),
|
||||
lyricuser: lrc.lyricUser,
|
||||
transuser: lrc.transUser,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link https://regexr.com/6e52n}
|
||||
*/
|
||||
const extractLrcRegex =
|
||||
/^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
|
||||
const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g
|
||||
|
||||
interface ParsedLyric {
|
||||
time: number
|
||||
rawTime: string
|
||||
content: string
|
||||
}
|
||||
|
||||
function parseLyric(lrc: string): ParsedLyric[] {
|
||||
// A sorted list of parsed lyric and its timestamp.
|
||||
const parsedLyrics: ParsedLyric[] = []
|
||||
|
||||
// Find the appropriate index to push our parsed lyric.
|
||||
const binarySearch = (lyric: ParsedLyric) => {
|
||||
const time = lyric.time
|
||||
|
||||
let low = 0
|
||||
let high = parsedLyrics.length - 1
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const midTime = parsedLyrics[mid].time
|
||||
if (midTime === time) {
|
||||
return mid
|
||||
} else if (midTime < time) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return low
|
||||
}
|
||||
|
||||
function trimContent(content: string): string {
|
||||
const t = content.trim()
|
||||
return t.length < 1 ? content : t
|
||||
}
|
||||
|
||||
for (const line of lrc.trim().matchAll(extractLrcRegex)) {
|
||||
const { lyricTimestamps, content } = line.groups as {
|
||||
lyricTimestamps: string
|
||||
content: string
|
||||
}
|
||||
|
||||
if (content === '纯音乐,请欣赏') continue
|
||||
|
||||
if (
|
||||
content.match(
|
||||
// https://regexr.com/6j8pf
|
||||
/.*(?<role>作曲|作词|编曲|制作|Producers|Producer|Produced|贝斯|工程师|吉他|合成器|助理|编程|制作|和声|母带|人声|鼓|混音|中提琴|编写|Talkbox|钢琴|出版|录音|发行|出品|键盘|弦乐|设计|监制|原曲|演唱|声明|版权|封面|插画|统筹|企划|填词|原唱|后期|和音|琵琶).*[::]\s*(?<name>.*)/
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const timestamp of lyricTimestamps.matchAll(extractTimestampRegex)) {
|
||||
const { min, sec, ms } = timestamp.groups as {
|
||||
min: string
|
||||
sec: string
|
||||
ms: string
|
||||
}
|
||||
const rawTime = timestamp[0]
|
||||
const time = Number(min) * 60 + Number(sec) + Number(ms ?? 0) * 0.001
|
||||
|
||||
const parsedLyric = { rawTime, time, content: trimContent(content) }
|
||||
parsedLyrics.splice(binarySearch(parsedLyric), 0, parsedLyric)
|
||||
}
|
||||
}
|
||||
|
||||
return parsedLyrics
|
||||
}
|
||||
471
packages/web/utils/player.ts
Normal file
471
packages/web/utils/player.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import { Howl, Howler } from 'howler'
|
||||
import {
|
||||
fetchAudioSourceWithReactQuery,
|
||||
fetchTracksWithReactQuery,
|
||||
} from '@/web/hooks/useTracks'
|
||||
import { fetchPersonalFMWithReactQuery } from '@/web/hooks/usePersonalFM'
|
||||
import { fmTrash } from '@/web/api/personalFM'
|
||||
import { cacheAudio } from '@/web/api/yesplaymusic'
|
||||
import { clamp } from 'lodash-es'
|
||||
import axios from 'axios'
|
||||
import { resizeImage } from './common'
|
||||
import { fetchPlaylistWithReactQuery } from '@/web/hooks/usePlaylist'
|
||||
import { fetchAlbumWithReactQuery } from '@/web/hooks/useAlbum'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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
|
||||
private _repeatMode: RepeatMode = RepeatMode.Off
|
||||
|
||||
state: State = State.Initializing
|
||||
mode: Mode = Mode.TrackList
|
||||
trackList: TrackID[] = []
|
||||
trackListSource: TrackListSource | null = null
|
||||
fmTrackList: TrackID[] = []
|
||||
shuffle: boolean = false
|
||||
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._repeatMode) this._repeatMode = params._repeatMode
|
||||
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.fmTrack) this.fmTrack = params.fmTrack
|
||||
|
||||
this.state = State.Ready
|
||||
this._playAudio(false) // just load the audio, not play
|
||||
this._initFM()
|
||||
|
||||
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
get repeatMode(): RepeatMode {
|
||||
return this._repeatMode
|
||||
}
|
||||
set repeatMode(value) {
|
||||
this._repeatMode = value
|
||||
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
|
||||
}
|
||||
|
||||
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 url = audio.includes('?')
|
||||
? `${audio}&ypm-id=${id}`
|
||||
: `${audio}?ypm-id=${id}`
|
||||
const howler = new Howl({
|
||||
src: [url],
|
||||
format: ['mp3', 'flac', 'webm'],
|
||||
html5: true,
|
||||
autoplay,
|
||||
volume: 1,
|
||||
onend: () => this._howlerOnEndCallback(),
|
||||
})
|
||||
_howler = howler
|
||||
window.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(new URL(audio).searchParams.get('ypm-id'))
|
||||
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()
|
||||
const ids = (response?.data?.map(r => r.id) ?? []).filter(
|
||||
r => !this.fmTrackList.includes(r)
|
||||
)
|
||||
this.fmTrackList.push(...ids)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
11
packages/web/utils/reactQueryClient.ts
Normal file
11
packages/web/utils/reactQueryClient.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { QueryClient } from 'react-query'
|
||||
|
||||
const reactQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default reactQueryClient
|
||||
37
packages/web/utils/request.ts
Normal file
37
packages/web/utils/request.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from 'axios'
|
||||
|
||||
const baseURL = String(
|
||||
import.meta.env.DEV ? '/netease' : import.meta.env.VITE_APP_NETEASE_API_URL
|
||||
)
|
||||
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
service.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
return config
|
||||
})
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response //.data
|
||||
return res
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
const request = async (config: AxiosRequestConfig) => {
|
||||
const { data } = await service.request(config)
|
||||
return data as any
|
||||
}
|
||||
|
||||
export default request
|
||||
8
packages/web/utils/theme.ts
Normal file
8
packages/web/utils/theme.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const changeAccentColor = (color: string) => {
|
||||
document.body.setAttribute('data-accent-color', color)
|
||||
}
|
||||
|
||||
const stateString = localStorage.getItem('state')
|
||||
const stateInLocalStorage = stateString ? JSON.parse(stateString) : {}
|
||||
|
||||
changeAccentColor(stateInLocalStorage?.settings?.accentColor || 'blue')
|
||||
Loading…
Add table
Add a link
Reference in a new issue