resources/assets/js/services/playbackService.spec.ts
import { nextTick, reactive } from 'vue'
import plyr from 'plyr'
import lodash from 'lodash'
import { expect, it, vi } from 'vitest'
import { noop } from '@/utils/helpers'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { http } from '@/services/http'
import { socketService } from '@/services/socketService'
import { preferenceStore as preferences } from '@/stores/preferenceStore'
import { queueStore } from '@/stores/queueStore'
import { songStore } from '@/stores/songStore'
import { userStore } from '@/stores/userStore'
import { commonStore } from '@/stores/commonStore'
import { recentlyPlayedStore } from '@/stores/recentlyPlayedStore'
import { playbackService } from '@/services/playbackService'
new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => this.setupEnvironment())
}
protected test () {
it('only initializes once', () => {
const spy = vi.spyOn(plyr, 'setup')
playbackService.init(document.querySelector('.plyr')!)
expect(spy).toHaveBeenCalled()
playbackService.init(document.querySelector('.plyr')!)
expect(spy).toHaveBeenCalledTimes(1)
})
it.each<[boolean, boolean, number, number, number]>([
[false, false, 100, 400, 1],
[true, false, 100, 400, 0],
[false, true, 100, 400, 0],
[false, false, 100, 500, 0],
])(
'when playCountRegistered is %s, isTranscoding is %s, current media time is %d, media duration is %d, then registerPlay() should be call %d times',
(playCountRegistered, isTranscoding, currentTime, duration, numberOfCalls) => {
const song = factory('song', {
play_count_registered: playCountRegistered,
playback_state: 'Playing',
})
this.setCurrentSong(song)
this.setReadOnlyProperty(playbackService, 'isTranscoding', isTranscoding)
playbackService.init(document.querySelector('.plyr')!)
const mediaElement = playbackService.player.media
// we can't set mediaElement.currentTime|duration directly because they're read-only
this.setReadOnlyProperty(mediaElement, 'currentTime', currentTime)
this.setReadOnlyProperty(mediaElement, 'duration', duration)
const registerPlayMock = this.mock(playbackService, 'registerPlay')
const putMock = this.mock(http, 'put')
mediaElement.dispatchEvent(new Event('timeupdate'))
expect(registerPlayMock).toHaveBeenCalledTimes(numberOfCalls)
expect(putMock).toHaveBeenCalledWith('queue/playback-status', {
song: song.id,
position: currentTime,
})
},
)
it('plays next song if current song is errored', () => {
playbackService.init(document.querySelector('.plyr')!)
const playNextMock = this.mock(playbackService, 'playNext')
playbackService.player!.media.dispatchEvent(new Event('error'))
expect(playNextMock).toHaveBeenCalled()
})
it('scrobbles if current song ends', () => {
commonStore.state.uses_last_fm = true
userStore.state.current = factory('user', {
preferences: {
lastfm_session_key: 'foo',
},
})
playbackService.init(document.querySelector('.plyr')!)
const scrobbleMock = this.mock(songStore, 'scrobble')
playbackService.player!.media.dispatchEvent(new Event('ended'))
expect(scrobbleMock).toHaveBeenCalled()
})
it.each<[RepeatMode, number, number]>([['REPEAT_ONE', 1, 0], ['NO_REPEAT', 0, 1], ['REPEAT_ALL', 0, 1]])(
'when song ends, if repeat mode is %s then restart() is called %d times and playNext() is called %d times',
(repeatMode, restartCalls, playNextCalls) => {
commonStore.state.uses_last_fm = false // so that no scrobbling is made unnecessarily
preferences.repeat_mode = repeatMode
playbackService.init(document.querySelector('.plyr')!)
const restartMock = this.mock(playbackService, 'restart')
const playNextMock = this.mock(playbackService, 'playNext')
playbackService.player!.media.dispatchEvent(new Event('ended'))
expect(restartMock).toHaveBeenCalledTimes(restartCalls)
expect(playNextMock).toHaveBeenCalledTimes(playNextCalls)
},
)
it.each([
[false, true, 300, 310, 0],
[true, false, 300, 310, 0],
[false, false, 300, 400, 0],
[false, false, 300, 310, 1],
])(
'when next song preloaded is %s, isTranscoding is %s, current media time is %d, media duration is %d, then preload() should be called %d times',
(preloaded, isTranscoding, currentTime, duration, numberOfCalls) => {
this.mock(playbackService, 'registerPlay')
this.setReadOnlyProperty(queueStore, 'next', factory('song', { preloaded }))
this.setReadOnlyProperty(playbackService, 'isTranscoding', isTranscoding)
playbackService.init(document.querySelector('.plyr')!)
const mediaElement = playbackService.player!.media
this.setReadOnlyProperty(mediaElement, 'currentTime', currentTime)
this.setReadOnlyProperty(mediaElement, 'duration', duration)
const preloadMock = this.mock(playbackService, 'preload')
this.mock(http, 'put')
mediaElement.dispatchEvent(new Event('timeupdate'))
expect(preloadMock).toHaveBeenCalledTimes(numberOfCalls)
},
)
it('registers play', () => {
const recentlyPlayedStoreAddMock = this.mock(recentlyPlayedStore, 'add')
const registerPlayMock = this.mock(songStore, 'registerPlay')
const song = factory('song')
playbackService.registerPlay(song)
expect(recentlyPlayedStoreAddMock).toHaveBeenCalledWith(song)
expect(registerPlayMock).toHaveBeenCalledWith(song)
expect(song.play_count_registered).toBe(true)
})
it('preloads a song', () => {
playbackService.init(document.querySelector('.plyr')!)
const audioElement = {
setAttribute: vi.fn(),
load: vi.fn(),
}
const createElementMock = this.mock(document, 'createElement', audioElement)
this.mock(songStore, 'getSourceUrl').mockReturnValue('/foo?token=o5afd')
const song = factory('song')
playbackService.preload(song)
expect(createElementMock).toHaveBeenCalledWith('audio')
expect(audioElement.setAttribute).toHaveBeenNthCalledWith(1, 'src', '/foo?token=o5afd')
expect(audioElement.setAttribute).toHaveBeenNthCalledWith(2, 'preload', 'auto')
expect(audioElement.load).toHaveBeenCalled()
expect(song.preloaded).toBe(true)
})
it('restarts a song', async () => {
playbackService.init(document.querySelector('.plyr')!)
const song = this.setCurrentSong()
this.mock(Math, 'floor', 1000)
const broadcastMock = this.mock(socketService, 'broadcast')
const showNotificationMock = this.mock(playbackService, 'showNotification')
const restartMock = this.mock(playbackService.player!, 'restart')
const putMock = this.mock(http, 'put')
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
await playbackService.restart()
expect(song.play_start_time).toEqual(1000)
expect(song.play_count_registered).toBe(false)
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
expect(showNotificationMock).toHaveBeenCalled()
expect(restartMock).toHaveBeenCalled()
expect(playMock).toHaveBeenCalled()
expect(putMock).toHaveBeenCalledWith('queue/playback-status', {
song: song.id,
position: 0,
})
})
it.each<[RepeatMode, RepeatMode]>([
['NO_REPEAT', 'REPEAT_ALL'],
['REPEAT_ALL', 'REPEAT_ONE'],
['REPEAT_ONE', 'NO_REPEAT'],
])('it switches from repeat mode %s to repeat mode %s', (fromMode, toMode) => {
playbackService.init(document.querySelector('.plyr')!)
preferences.repeat_mode = fromMode
playbackService.rotateRepeatMode()
expect(preferences.repeat_mode).toEqual(toMode)
})
it('restarts song if playPrev is triggered after 5 seconds', async () => {
this.setCurrentSong()
playbackService.init(document.querySelector('.plyr')!)
const mock = this.mock(playbackService.player!, 'restart')
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 6)
await playbackService.playPrev()
expect(mock).toHaveBeenCalled()
})
it('stops if playPrev is triggered when there is no prev song and repeat mode is NO_REPEAT', async () => {
playbackService.init(document.querySelector('.plyr')!)
const stopMock = this.mock(playbackService, 'stop')
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 4)
this.setReadOnlyProperty(playbackService, 'previous', undefined)
preferences.repeat_mode = 'NO_REPEAT'
await playbackService.playPrev()
expect(stopMock).toHaveBeenCalled()
})
it('plays the previous song', async () => {
playbackService.init(document.querySelector('.plyr')!)
const previousSong = factory('song')
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 4)
this.setReadOnlyProperty(playbackService, 'previous', previousSong)
const playMock = this.mock(playbackService, 'play')
await playbackService.playPrev()
expect(playMock).toHaveBeenCalledWith(previousSong)
})
it('stops if playNext is triggered when there is no next song and repeat mode is NO_REPEAT', async () => {
playbackService.init(document.querySelector('.plyr')!)
this.setReadOnlyProperty(playbackService, 'next', undefined)
preferences.repeat_mode = 'NO_REPEAT'
const stopMock = this.mock(playbackService, 'stop')
await playbackService.playNext()
expect(stopMock).toHaveBeenCalled()
})
it('plays the next song', async () => {
playbackService.init(document.querySelector('.plyr')!)
const nextSong = factory('song')
this.setReadOnlyProperty(playbackService, 'next', nextSong)
const playMock = this.mock(playbackService, 'play')
await playbackService.playNext()
expect(playMock).toHaveBeenCalledWith(nextSong)
})
it('stops playback', () => {
playbackService.init(document.querySelector('.plyr')!)
const currentSong = this.setCurrentSong()
const pauseMock = this.mock(playbackService.player!, 'pause')
const seekMock = this.mock(playbackService.player!, 'seek')
const broadcastMock = this.mock(socketService, 'broadcast')
playbackService.stop()
expect(currentSong.playback_state).toEqual('Stopped')
expect(pauseMock).toHaveBeenCalled()
expect(seekMock).toHaveBeenCalledWith(0)
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_PLAYBACK_STOPPED')
expect(document.title).toEqual('Koel')
})
it('pauses playback', () => {
playbackService.init(document.querySelector('.plyr')!)
const song = this.setCurrentSong()
const pauseMock = this.mock(playbackService.player!, 'pause')
const broadcastMock = this.mock(socketService, 'broadcast')
playbackService.pause()
expect(song.playback_state).toEqual('Paused')
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
expect(pauseMock).toHaveBeenCalled()
})
it('resumes playback', async () => {
const song = this.setCurrentSong(factory('song', {
playback_state: 'Paused',
}))
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
const broadcastMock = this.mock(socketService, 'broadcast')
playbackService.init(document.querySelector('.plyr')!)
await playbackService.resume()
expect(queueStore.current?.playback_state).toEqual('Playing')
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
expect(playMock).toHaveBeenCalled()
})
it('plays first in queue if toggled when there is no current song', async () => {
playbackService.init(document.querySelector('.plyr')!)
queueStore.state.playables = []
const playFirstInQueueMock = this.mock(playbackService, 'playFirstInQueue')
await playbackService.toggle()
expect(playFirstInQueueMock).toHaveBeenCalled()
})
it.each<[MethodOf<typeof playbackService>, PlaybackState]>([
['resume', 'Paused'],
['pause', 'Playing'],
])('%ss playback if toggled when current song playback state is %s', async (action, playbackState) => {
playbackService.init(document.querySelector('.plyr')!)
this.setCurrentSong(factory('song', { playback_state: playbackState }))
const actionMock = this.mock(playbackService, action)
await playbackService.toggle()
expect(actionMock).toHaveBeenCalled()
})
it('queues and plays songs without shuffling', async () => {
playbackService.init(document.querySelector('.plyr')!)
const songs = factory('song', 5)
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
const playMock = this.mock(playbackService, 'play')
const firstSongInQueue = songs[0]
const shuffleMock = this.mock(lodash, 'shuffle')
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
playbackService.queueAndPlay(songs)
await nextTick()
expect(shuffleMock).not.toHaveBeenCalled()
expect(replaceQueueMock).toHaveBeenCalledWith(songs)
expect(playMock).toHaveBeenCalledWith(firstSongInQueue)
})
it('queues and plays songs with shuffling', async () => {
playbackService.init(document.querySelector('.plyr')!)
const songs = factory('song', 5)
const shuffledSongs = factory('song', 5)
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
const playMock = this.mock(playbackService, 'play')
const firstSongInQueue = songs[0]
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
const shuffleMock = this.mock(lodash, 'shuffle', shuffledSongs)
playbackService.queueAndPlay(songs, true)
await nextTick()
expect(shuffleMock).toHaveBeenCalledWith(songs)
expect(replaceQueueMock).toHaveBeenCalledWith(shuffledSongs)
expect(playMock).toHaveBeenCalledWith(firstSongInQueue)
})
it('plays first song in queue', async () => {
playbackService.init(document.querySelector('.plyr')!)
const songs = factory('song', 5)
queueStore.state.playables = songs
this.setReadOnlyProperty(queueStore, 'first', songs[0])
const playMock = this.mock(playbackService, 'play')
await playbackService.playFirstInQueue()
expect(playMock).toHaveBeenCalledWith(songs[0])
})
}
private setupEnvironment () {
document.body.innerHTML = `
<div class="plyr">
<audio crossorigin="anonymous" controls/>
</div>
`
window.AudioContext = vi.fn().mockImplementation(() => ({
createMediaElementSource: vi.fn(noop),
}))
}
private setCurrentSong (song?: Playable) {
song = reactive(song || factory('song', {
playback_state: 'Playing',
}))
queueStore.state.playables = reactive([song])
return song
}
}