Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/components/fullscreen/lyrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { subsonic } from '@/service/subsonic'
import { useLang } from '@/store/lang.store'
import { usePlayerRef, usePlayerSonglist } from '@/store/player.store'
import { ILyric } from '@/types/responses/song'
import { playbackClock } from '@/utils/playbackClock'
import { queryKeys } from '@/utils/queryKeys'

// disambiguates chinese language code to the user's locale if set
Expand Down Expand Up @@ -68,7 +69,9 @@ function SyncedLyrics({ lyrics }: LyricProps) {
const resolvedLang = resolveLyricsLang(lyrics.lang, langCode)

setTimeout(() => {
let newProgress = (playerRef?.currentTime || 0) * 1000
// Both players publish here, so this is the same smooth position whether
// audio comes from the <audio> element or the gapless Web Audio engine.
let newProgress = playbackClock.getPositionMs()

if (newProgress === progress) {
newProgress += 1 // Prevents the lyrics from getting stuck when the audio is still loading
Expand Down
50 changes: 50 additions & 0 deletions src/app/components/player/audio.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { usePlayerStore } from '@/store/player.store'
import { ISong } from '@/types/responses/song'
import { playbackClock } from '@/utils/playbackClock'
import { Player } from './player'

// Guards the stock <AudioPlayer>'s side of the shared playback clock: while a
// song plays it must publish a high-resolution position every frame (synced
// lyrics poll it). The gapless engine's side is covered in
// gapless-song-player.cy.tsx; both players must feed the same clock.
describe('AudioPlayer playback clock wiring', () => {
beforeEach(() => {
cy.mockCoverArt()
cy.mockSongStream()
cy.stub(HTMLMediaElement.prototype, 'play').resolves()
})

it('publishes the element position to the shared clock while playing', () => {
cy.fixture('songs/random').then((songs: ISong[]) => {
playbackClock.reset()
cy.spy(playbackClock, 'setPositionMs').as('clock')

usePlayerStore.getState().settings.playback.setTransitionMode('none')
usePlayerStore.getState().actions.setSongList(songs, 0)
usePlayerStore.getState().actions.setPlayingState(true)

cy.mount(<Player />)

cy.get('@clock').should('have.been.called')
})
})

it('does not publish while paused', () => {
cy.fixture('songs/random').then((songs: ISong[]) => {
playbackClock.reset()

usePlayerStore.getState().settings.playback.setTransitionMode('none')
usePlayerStore.getState().actions.setSongList(songs, 0)
usePlayerStore.getState().actions.setPlayingState(false)

cy.mount(<Player />)

// Give the rAF publisher a few frames; it must stay quiet when paused.
cy.wait(100).then(() => {
cy.spy(playbackClock, 'setPositionMs').as('clock')
cy.wait(100)
cy.get('@clock').should('not.have.been.called')
})
})
})
})
16 changes: 16 additions & 0 deletions src/app/components/player/audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
useReplayGainState,
} from '@/store/player.store'
import { logger } from '@/utils/logger'
import { playbackClock } from '@/utils/playbackClock'
import { calculateReplayGain, ReplayGainParams } from '@/utils/replayGain'

type AudioPlayerProps = ComponentPropsWithoutRef<'audio'> & {
Expand Down Expand Up @@ -133,6 +134,21 @@ export function AudioPlayer({
if (isRadio) handleRadio()
}, [audioRef, isPlaying, isRadio])

// Publish a high-resolution playback position to the shared clock for consumers that poll it (synced lyrics).
useEffect(() => {
if (!isSong || !isPlaying) return

let raf = 0
const tick = () => {
const audio = audioRef.current
if (audio) playbackClock.setPositionMs(audio.currentTime * 1000)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)

return () => cancelAnimationFrame(raf)
}, [audioRef, isSong, isPlaying])

const handleError = useMemo(() => {
if (isSong) return handleSongError
if (isRadio) return handleRadioError
Expand Down
74 changes: 74 additions & 0 deletions src/app/components/player/gapless-song-player.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { usePlayerStore } from '@/store/player.store'
import { ISong } from '@/types/responses/song'
import { playbackClock } from '@/utils/playbackClock'
import { Player } from './player'

// Guards the transitionMode branch added to <Player />: the correct audio
// surface must mount for each mode. The gapless engine's audio behaviour
// (seams, crossfades, decode) is verified manually. See gapless-song-player.tsx.
describe('Gapless playback wiring', () => {
beforeEach(() => {
cy.mockCoverArt()
cy.mockSongStream()
})

// The store persists across specs in a run; don't leak gapless mode into
// specs that expect the stock player (e.g. player.cy.tsx).
afterEach(() => {
usePlayerStore.getState().settings.playback.setTransitionMode('none')
})

it('mounts the gapless surface (not the stock player) when enabled', () => {
cy.fixture('songs/random').then((songs: ISong[]) => {
usePlayerStore.getState().settings.playback.setTransitionMode('gapless')
usePlayerStore.getState().actions.setSongList(songs, 0)
usePlayerStore.getState().actions.setPlayingState(false)

cy.mount(<Player />)

cy.getByTestId('gapless-ui-surface').should('exist')
cy.getByTestId('player-song-audio').should('not.exist')

cy.getByTestId<HTMLAudioElement>('gapless-ui-surface').should(($audio) => {
expect($audio[0].getAttribute('src')).to.contain('stream')
})

// The OS media session anchor must accompany the gapless engine (its
// Web Audio output can't anchor a session on its own).
cy.getByTestId('media-session-keep-alive').should('exist')
})
})

it('mounts the stock player (not the gapless surface) when disabled', () => {
cy.fixture('songs/random').then((songs: ISong[]) => {
usePlayerStore.getState().settings.playback.setTransitionMode('none')
usePlayerStore.getState().actions.setSongList(songs, 0)
usePlayerStore.getState().actions.setPlayingState(false)

cy.mount(<Player />)

cy.getByTestId('player-song-audio').should('exist')
cy.getByTestId('gapless-ui-surface').should('not.exist')

// The stock element anchors the media session itself; no keep-alive.
cy.getByTestId('media-session-keep-alive').should('not.exist')
})
})

it('publishes the playback position to the shared clock on track dispatch', () => {
cy.fixture('songs/random').then((songs: ISong[]) => {
playbackClock.reset()
cy.spy(playbackClock, 'setPositionMs').as('clock')

usePlayerStore.getState().settings.playback.setTransitionMode('gapless')
usePlayerStore.getState().actions.setSongList(songs, 0)
usePlayerStore.getState().actions.setPlayingState(false)

cy.mount(<Player />)

// Cold-start dispatch seeds the clock even while paused, so synced
// lyrics read a correct position the moment playback starts.
cy.get('@clock').should('have.been.called')
})
})
})
Loading