| import { useSocketContext } from '@/providers/socket-context' |
| import { usePlayerStore } from '@/stores/playerStore' |
| import { useRoomStore } from '@/stores/roomStore' |
| import { useAccountStore } from '@/stores/accountStore' |
| import { isBilibiliCoverUrl } from '@/lib/cover' |
| import type { VoteAction } from '@music-together/shared' |
| import { defineAbilityFor, EVENTS, TIMING } from '@music-together/shared' |
| import { useCallback, useEffect, useMemo, useRef } from 'react' |
| import { toast } from 'sonner' |
| import { getVoteActionLabel } from './useVote' |
|
|
| interface MediaSessionControls { |
| play: () => void |
| pause: () => void |
| next: () => void |
| prev: () => void |
| seek: (time: number) => void |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function useMediaSession({ play, pause, next, prev, seek }: MediaSessionControls) { |
| const { socket } = useSocketContext() |
| |
| |
| |
| |
| const role = useRoomStore((s) => s.currentUser?.role ?? 'member') |
| const isServerAdmin = useAccountStore((state) => state.profile?.role === 'admin') |
| const ability = useMemo(() => defineAbilityFor(isServerAdmin ? 'owner' : role), [isServerAdmin, role]) |
| const canPlay = ability.can('play', 'Player') |
| const canSeek = ability.can('seek', 'Player') |
| const canNext = ability.can('next', 'Player') |
| const canPrev = ability.can('prev', 'Player') |
| const canVote = ability.can('vote', 'Player') |
|
|
| |
| |
| const callbacksRef = useRef({ play, pause, next, prev, seek }) |
| useEffect(() => { |
| callbacksRef.current = { play, pause, next, prev, seek } |
| }, [play, pause, next, prev, seek]) |
|
|
| |
| |
| |
| |
| |
| const lastVoteRef = useRef(0) |
| const startVote = useCallback( |
| (action: VoteAction) => { |
| const now = Date.now() |
| if (now - lastVoteRef.current < TIMING.PLAYER_NEXT_DEBOUNCE_MS) return |
| lastVoteRef.current = now |
| socket.emit(EVENTS.VOTE_START, { action }) |
| toast.info(`已发起投票:${getVoteActionLabel(action)}`) |
| }, |
| [socket], |
| ) |
|
|
| |
| |
| useEffect(() => { |
| if (!('mediaSession' in navigator)) return |
| const ms = navigator.mediaSession |
|
|
| |
| if (canPlay) { |
| ms.setActionHandler('play', () => callbacksRef.current.play()) |
| ms.setActionHandler('pause', () => callbacksRef.current.pause()) |
| } else if (canVote) { |
| ms.setActionHandler('play', () => startVote('resume')) |
| ms.setActionHandler('pause', () => startVote('pause')) |
| } else { |
| ms.setActionHandler('play', null) |
| ms.setActionHandler('pause', null) |
| } |
|
|
| |
| if (canNext) { |
| ms.setActionHandler('nexttrack', () => callbacksRef.current.next()) |
| } else if (canVote) { |
| ms.setActionHandler('nexttrack', () => startVote('next')) |
| } else { |
| ms.setActionHandler('nexttrack', null) |
| } |
|
|
| |
| if (canPrev) { |
| ms.setActionHandler('previoustrack', () => callbacksRef.current.prev()) |
| } else if (canVote) { |
| ms.setActionHandler('previoustrack', () => startVote('prev')) |
| } else { |
| ms.setActionHandler('previoustrack', null) |
| } |
|
|
| |
| if (canSeek) { |
| ms.setActionHandler('seekto', (details) => { |
| if (details.seekTime != null) { |
| callbacksRef.current.seek(details.seekTime) |
| } |
| }) |
| } else { |
| ms.setActionHandler('seekto', null) |
| } |
|
|
| return () => { |
| ms.setActionHandler('play', null) |
| ms.setActionHandler('pause', null) |
| ms.setActionHandler('nexttrack', null) |
| ms.setActionHandler('previoustrack', null) |
| ms.setActionHandler('seekto', null) |
| } |
| }, [canPlay, canSeek, canNext, canPrev, canVote, startVote]) |
|
|
| |
| const currentTrack = usePlayerStore((s) => s.currentTrack) |
| useEffect(() => { |
| if (!('mediaSession' in navigator)) return |
| const ms = navigator.mediaSession |
|
|
| if (currentTrack) { |
| const metadata = { |
| title: currentTrack.title, |
| artist: currentTrack.artist.join(' / '), |
| album: currentTrack.album || '', |
| } |
| const directArtwork = currentTrack.cover |
| ? [{ src: currentTrack.cover, sizes: '512x512', type: 'image/jpeg' }] |
| : [] |
|
|
| if (!currentTrack.cover || !isBilibiliCoverUrl(currentTrack.cover)) { |
| ms.metadata = new MediaMetadata({ ...metadata, artwork: directArtwork }) |
| } else { |
| const controller = new AbortController() |
| let objectUrl: string | null = null |
|
|
| ms.metadata = new MediaMetadata(metadata) |
| void fetch(currentTrack.cover, { |
| signal: controller.signal, |
| referrerPolicy: 'no-referrer', |
| }) |
| .then((response) => { |
| if (!response.ok) throw new Error(`Artwork request failed: ${response.status}`) |
| return response.blob() |
| }) |
| .then((blob) => { |
| if (controller.signal.aborted) return |
| objectUrl = URL.createObjectURL(blob) |
| ms.metadata = new MediaMetadata({ |
| ...metadata, |
| artwork: [{ src: objectUrl, sizes: '512x512', type: blob.type || 'image/jpeg' }], |
| }) |
| }) |
| .catch(() => { |
| |
| }) |
|
|
| return () => { |
| controller.abort() |
| if (objectUrl) URL.revokeObjectURL(objectUrl) |
| ms.metadata = null |
| } |
| } |
| } else { |
| ms.metadata = null |
| } |
|
|
| return () => { |
| ms.metadata = null |
| } |
| }, [currentTrack]) |
|
|
| |
| const isPlaying = usePlayerStore((s) => s.isPlaying) |
| useEffect(() => { |
| if (!('mediaSession' in navigator)) return |
| navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused' |
|
|
| return () => { |
| navigator.mediaSession.playbackState = 'none' |
| } |
| }, [isPlaying]) |
|
|
| |
| |
| |
| const currentTime = usePlayerStore((s) => s.currentTime) |
| const duration = usePlayerStore((s) => s.duration) |
| const prevIsPlayingRef = useRef(isPlaying) |
| const prevDurationRef = useRef(duration) |
| const prevPositionRef = useRef(currentTime) |
|
|
| useEffect(() => { |
| if (!('mediaSession' in navigator)) return |
| if (!duration || !isFinite(duration)) return |
|
|
| const playStateChanged = isPlaying !== prevIsPlayingRef.current |
| const durationChanged = duration !== prevDurationRef.current |
| |
| const expectedDelta = prevIsPlayingRef.current ? 1 : 0 |
| const isSeek = Math.abs(currentTime - prevPositionRef.current - expectedDelta) > 2 |
|
|
| if (!playStateChanged && !durationChanged && !isSeek) return |
|
|
| prevIsPlayingRef.current = isPlaying |
| prevDurationRef.current = duration |
| prevPositionRef.current = currentTime |
|
|
| try { |
| navigator.mediaSession.setPositionState({ |
| duration, |
| playbackRate: 1, |
| position: Math.min(Math.max(0, currentTime), duration), |
| }) |
| } catch { |
| |
| } |
| }, [isPlaying, duration, currentTime]) |
| } |
|
|