feat: 实现私人FM (#1453)

* feat: 实现私人FM

* 根据建议修改

* fix: APP启动时FMCard无法加载数据

* fix: coverUrl使用useMemo

* fix: 在私人FM模式下禁用单曲循环

* fix: 私人FM模式下禁用部分按钮

* fix: 限制FMCard的歌手长度

* fix: 移除ArtistsInline的clamp参数,并将其作为隐式Fallback
This commit is contained in:
memorydream 2022-04-02 02:13:48 +08:00 committed by GitHub
parent e99c4833f7
commit 719a3a60d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 351 additions and 40 deletions

View file

@ -0,0 +1,116 @@
import request from '@/utils/request'
export enum PersonalFMApiNames {
FETCH_PERSONAL_FM = 'fetchPersonalFM',
}
export interface PersonalMusic {
name: null | string
id: number
size: number
extension: 'mp3' | 'flac' | null
sr: number
dfsId: number
bitrate: number
playTime: number
volumeDelta: number
}
export interface FetchPersonalFMResponse {
code: number
popAdjust: boolean
data: {
name: string
id: number
position: number
alias: string[]
status: number
fee: number
copyrightId: number
disc?: string
no: number
artists: Artist[]
album: Album
starred: boolean
popularity: number
score: number
starredNum: number
duration: number
playedNum: number
dayPlays: number
hearTime: number
ringtone: null
crbt: null
audition: null
copyFrom: string
commentThreadId: string
rtUrl: string | null
ftype: number
rtUrls: (string | null)[]
copyright: number
transName: null | string
sign: null
mark: number
originCoverType: number
originSongSimpleData: null
single: number
noCopyrightRcmd: null
mvid: number
bMusic?: PersonalMusic
lMusic?: PersonalMusic
mMusic?: PersonalMusic
hMusic?: PersonalMusic
reason: string
privilege: {
id: number
fee: number
payed: number
st: number
pl: number
dl: number
sp: number
cp: number
subp: number
cs: boolean
maxbr: number
fl: number
toast: boolean
flag: number
preShell: boolean
playMaxbr: number
downloadMaxbr: number
rscl: null
freeTrialPrivilege: {
[key: string]: unknown
}
chargeInfoList: {
[key: string]: unknown
}[]
}
alg: string
s_ctrp: string
}[]
}
export function fetchPersonalFM(): Promise<FetchPersonalFMResponse> {
return request({
url: '/personal/fm',
method: 'get',
})
}
export interface FMTrashResponse {
songs: null[]
code: number
count: number
}
export function fmTrash(id: number): Promise<FMTrashResponse> {
return request({
url: '/fm/trash',
method: 'post',
params: {
id,
},
})
}

View file

@ -15,7 +15,12 @@ const ArtistInline = ({
}
return (
<div className={classNames('line-clamp-1', className)}>
<div
className={classNames(
!className?.includes('line-clamp') && 'line-clamp-1',
className
)}
>
{artists.map((artist, index) => (
<span key={`${artist.id}-${artist.name}`}>
<span

View file

@ -1,19 +1,71 @@
import { average } from 'color.js'
import { colord } from 'colord'
import { player } from '@/store'
import { resizeImage } from '@/utils/common'
import SvgIcon from '@/components/SvgIcon'
import ArtistInline from '@/components/ArtistsInline'
import { State as PlayerState, Mode as PlayerMode } from '@/utils/player'
enum ACTION {
DISLIKE = 'dislike',
PLAY = 'play',
NEXT = 'next',
const MediaControls = () => {
const classes =
'btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10'
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const playOrPause = () => {
if (playerSnapshot.mode === PlayerMode.FM) {
player.playOrPause()
} else {
player.playFM()
}
}
return (
<div>
<button
key='dislike'
className={classes}
onClick={() => player.fmTrash()}
>
<SvgIcon name='dislike' className='h-6 w-6' />
</button>
<button key='play' className={classes} onClick={playOrPause}>
<SvgIcon
className='h-6 w-6'
name={
playerSnapshot.mode === PlayerMode.FM &&
[PlayerState.PLAYING, PlayerState.LOADING].includes(state)
? 'pause'
: 'play'
}
/>
</button>
<button
key='next'
className={classes}
onClick={() => player.nextTrack(true)}
>
<SvgIcon name='next' className='h-6 w-6' />
</button>
</div>
)
}
const FMCard = () => {
const coverUrl =
'https://p1.music.126.net/lEzPSOjusKaRXKXT3987lQ==/109951166035876388.jpg?param=512y512'
const [background, setBackground] = useState('')
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack])
const coverUrl = useMemo(
() => resizeImage(playerSnapshot.fmTrack?.al?.picUrl ?? '', 'md'),
[playerSnapshot.fmTrack]
)
useEffect(() => {
if (coverUrl) {
average(coverUrl, { amount: 1, format: 'hex', sample: 1 }).then(color => {
const to = colord(color as string)
.darken(0.15)
@ -21,6 +73,9 @@ const FMCard = () => {
.toHex()
setBackground(`linear-gradient(to bottom right, ${color}, ${to})`)
})
} else {
setBackground(`linear-gradient(to bottom right, #66ccff, #ee0000)`)
}
}, [coverUrl])
return (
@ -28,28 +83,20 @@ const FMCard = () => {
className='relative flex h-[198px] overflow-hidden rounded-2xl p-4'
style={{ background }}
>
<img className='rounded-lg shadow-2xl' src={coverUrl} />
{coverUrl && <img className='rounded-lg shadow-2xl' src={coverUrl} />}
<div className='ml-5 flex w-full flex-col justify-between text-white'>
{/* Track info */}
<div>
<div className='text-xl font-semibold'>How Can I Make It OK?</div>
<div className='opacity-75'>Wolf Alice</div>
<div className='text-xl font-semibold'>{track?.name}</div>
<ArtistInline
className='line-clamp-2 opacity-75'
artists={track?.ar ?? []}
/>
</div>
<div className='-mb-1 flex items-center justify-between'>
{/* Actions */}
<div>
{Object.values(ACTION).map(action => (
<button
key={action}
className='btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10'
>
<SvgIcon name={action} className='h-6 w-6' />
</button>
))}
</div>
<MediaControls />
{/* FM logo */}
<div className='right-4 bottom-5 flex text-white opacity-20'>
@ -62,4 +109,11 @@ const FMCard = () => {
)
}
/**
* player的构造函数中调用initFM
* APP启动时不会加载私人FM的数据
*
*/
player.initFM()
export default FMCard

View file

@ -4,7 +4,7 @@ import Slider from '@/components/Slider'
import SvgIcon from '@/components/SvgIcon'
import { player } from '@/store'
import { resizeImage } from '@/utils/common'
import { State as PlayerState } from '@/utils/player'
import { State as PlayerState, Mode as PlayerMode } from '@/utils/player'
const PlayingTrack = () => {
const navigate = useNavigate()
@ -74,11 +74,22 @@ const MediaControls = () => {
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
return (
<div className='flex items-center justify-center gap-2 text-black dark:text-white'>
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
{mode === PlayerMode.PLAYLIST && (
<IconButton
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<SvgIcon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
@ -101,15 +112,20 @@ const MediaControls = () => {
}
const Others = () => {
const playerSnapshot = useSnapshot(player)
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
const isFM = () => mode === PlayerMode.FM
return (
<div className='flex items-center justify-end gap-2 pr-2 text-black dark:text-white'>
<IconButton onClick={() => toast('Work in progress')}>
<IconButton onClick={() => toast('Work in progress')} disabled={isFM()}>
<SvgIcon className='h-6 w-6' name='playlist' />
</IconButton>
<IconButton onClick={() => toast('Work in progress')}>
<IconButton onClick={() => toast('Work in progress')} disabled={isFM()}>
<SvgIcon className='h-6 w-6' name='repeat' />
</IconButton>
<IconButton onClick={() => toast('Work in progress')}>
<IconButton onClick={() => toast('Work in progress')} disabled={isFM()}>
<SvgIcon className='h-6 w-6' name='shuffle' />
</IconButton>
<IconButton onClick={() => toast('Work in progress')}>

View file

@ -0,0 +1,14 @@
import { fetchPersonalFM, PersonalFMApiNames } from '@/api/personalFM'
import reactQueryClient from '@/utils/reactQueryClient'
export function fetchPersonalFMWithReactQuery() {
return reactQueryClient.fetchQuery(
PersonalFMApiNames.FETCH_PERSONAL_FM,
() => {
return fetchPersonalFM()
},
{
retry: 3,
}
)
}

View file

@ -3,6 +3,8 @@ import {
fetchAudioSourceWithReactQuery,
fetchTracksWithReactQuery,
} from '@/hooks/useTracks'
import { fetchPersonalFMWithReactQuery } from '@/hooks/usePersonalFM'
import { fmTrash } from '@/api/personalFM'
import { cacheAudio } from '@/api/yesplaymusic'
import { clamp } from 'lodash-es'
@ -41,11 +43,14 @@ export class Player {
private _progress: number = 0
private _progressInterval: ReturnType<typeof setInterval> | undefined
private _volume: number = 1 // 0 to 1
private _fmTrack: Track | null = null
private _fmInited = false
state: State = State.INITIALIZING
mode: Mode = Mode.PLAYLIST
trackList: TrackID[] = []
trackListSource: TrackListSource | null = null
fmTrackList: TrackID[] = []
shuffle: boolean = false
repeatMode: RepeatMode = RepeatMode.OFF
@ -89,9 +94,12 @@ export class Player {
* Get current playing track ID
*/
get trackID(): TrackID {
if (this.mode === Mode.PLAYLIST) {
const { trackList, _trackIndex } = this
return trackList[_trackIndex] ?? 0
}
return this.fmTrackList[0] ?? 0
}
/**
* Get current playing track
@ -100,6 +108,10 @@ export class Player {
return this._track ?? null
}
get fmTrack(): Track | null {
return this._fmTrack ?? null
}
/**
* Get/Set progress of current track
*/
@ -154,6 +166,7 @@ export class Player {
private async _playTrack() {
const track = await this._fetchTrack(this.trackID)
if (track) this._track = track
if (track && this.mode === Mode.FM) this._fmTrack = track
this._playAudio()
}
@ -188,7 +201,7 @@ export class Player {
private _howlerOnEndCallback() {
console.log('_howlerOnEndCallback')
if (this.repeatMode === RepeatMode.ONE) {
if (this.mode !== Mode.FM && this.repeatMode === RepeatMode.ONE) {
_howler.seek(0)
_howler.play()
} else {
@ -201,6 +214,27 @@ export class Player {
cacheAudio(id, audio)
}
private async _nextFMTrack() {
if (this.fmTrackList.length <= 1) {
for (let i = 0; i < 5; i++) {
const response = await fetchPersonalFMWithReactQuery()
if (!response?.data?.length) continue
this.fmTrackList.shift()
this.fmTrackList.push(...response?.data?.map(r => r.id))
this._playTrack()
break
}
} else {
this.fmTrackList.shift()
this._playTrack()
if (this.fmTrackList.length <= 1) {
const response = await fetchPersonalFMWithReactQuery()
this.fmTrackList.push(...response?.data?.map(r => r.id))
}
}
}
/**
* Play current track
* @param {boolean} fade fade in
@ -255,6 +289,10 @@ export class Player {
* Play previous track
*/
prevTrack() {
if (this.mode === Mode.FM) {
toast('Personal FM not support previous track')
return
}
if (this._prevTrackIndex === undefined) {
toast('No previous track')
return
@ -266,8 +304,13 @@ export class Player {
/**
* Play next track
*/
nextTrack() {
nextTrack(forceFM: boolean = false) {
console.log(this)
if (forceFM || this.mode === Mode.FM) {
this.mode = Mode.FM
this._nextFMTrack()
return
}
if (this._nextTrackIndex === undefined) {
toast('No next track')
this.pause()
@ -316,6 +359,69 @@ export class Player {
this._playTrack()
}
/**
* 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()
}
}
/**
* Init personal fm
* should only be called in components/FMCard
*/
async initFM() {
if (this._fmInited) return
const response = await fetchPersonalFMWithReactQuery()
this.fmTrackList.push(...response?.data?.map(r => r.id))
const trackId = this.fmTrackList[0]
const track = await this._fetchTrack(trackId)
if (track) this._fmTrack = track
this._fmInited = true
}
/**
* Trash current PersonalFMTrack
*/
async fmTrash() {
let trashId = this.fmTrackList.shift() ?? 0
if (trashId === 0) return
if (this.mode === Mode.FM) {
await this._nextFMTrack()
} else {
for (let i = 0; i < 5 && this.fmTrackList.length <= 1; i++) {
const response = await fetchPersonalFMWithReactQuery()
this.fmTrackList.push(...response?.data?.map(r => r.id))
}
for (let i = 0; i < 5; i++) {
let track = await this._fetchTrack(this.fmTrackList.at(0) ?? 0)
if (track) {
this._fmTrack = track
break
} else {
this.fmTrackList.shift()
if (this.fmTrackList.length <= 1) {
const response = await fetchPersonalFMWithReactQuery()
this.fmTrackList.push(...response?.data?.map(r => r.id))
}
}
}
}
fmTrash(trashId)
}
/**
* Play track in trackList by id
*/