koel/resources/assets/js/services/playbackService.ts

391 lines
10 KiB
TypeScript
Raw Normal View History

import isMobile from 'ismobilejs'
2022-04-15 14:24:30 +00:00
import plyr from 'plyr'
import { shuffle, throttle } from 'lodash'
2022-04-15 14:24:30 +00:00
import {
2022-04-24 08:50:45 +00:00
commonStore,
preferenceStore as preferences,
2022-04-15 14:24:30 +00:00
queueStore,
recentlyPlayedStore,
2022-04-24 08:50:45 +00:00
songStore,
userStore
2022-04-15 14:24:30 +00:00
} from '@/stores'
2022-08-01 11:40:52 +00:00
import { arrayify, eventBus, isAudioContextSupported, logger } from '@/utils'
2022-04-24 08:50:45 +00:00
import { audioService, socketService } from '@/services'
2022-04-15 14:24:30 +00:00
/**
* The number of seconds before the current song ends to start preload the next one.
*/
const PRELOAD_BUFFER = 30
const DEFAULT_VOLUME_VALUE = 7
const VOLUME_INPUT_SELECTOR = '#volumeInput'
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
class PlaybackService {
// @ts-ignore
2022-07-25 18:23:30 +00:00
public player: Plyr
// @ts-ignore
2022-07-25 18:23:30 +00:00
private volumeInput: HTMLInputElement
private repeatModes: RepeatMode[] = ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']
private initialized = false
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public init () {
2022-04-15 14:24:30 +00:00
if (this.initialized) {
return
}
2022-07-25 18:23:30 +00:00
this.initialized = true
2022-05-14 14:45:48 +00:00
this.player = plyr.setup('.plyr', {
2022-04-15 14:24:30 +00:00
controls: []
})[0]
this.volumeInput = document.querySelector<HTMLInputElement>(VOLUME_INPUT_SELECTOR)!
this.listenToMediaEvents(this.player.media)
if (isAudioContextSupported) {
try {
this.setVolume(preferences.volume)
2022-04-24 08:50:45 +00:00
} catch (e) {
}
2022-04-15 14:24:30 +00:00
audioService.init(this.player.media)
eventBus.emit('INIT_EQUALIZER')
}
2022-04-30 10:36:35 +00:00
this.setMediaSessionActionHandlers()
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public registerPlay (song: Song) {
2022-04-15 14:24:30 +00:00
recentlyPlayedStore.add(song)
songStore.registerPlay(song)
2022-06-10 10:47:46 +00:00
song.play_count_registered = true
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public preload (song: Song) {
2022-04-15 14:24:30 +00:00
const audioElement = document.createElement('audio')
audioElement.setAttribute('src', songStore.getSourceUrl(song))
audioElement.setAttribute('preload', 'auto')
audioElement.load()
song.preloaded = true
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* Play a song. Because
*
* So many adventures couldn't happen today,
* So many songs we forgot to play
* So many dreams swinging out of the blue
* We'll let them come true
*/
2022-07-25 18:23:30 +00:00
public async play (song: Song) {
// If for any reason (most likely a bug), the requested song has been deleted, just attempt the next song.
if (song.deleted) {
logger.warn('Attempted to play a deleted song', song)
if (this.next && this.next.id !== song.id) {
await this.playNext()
}
return
}
document.title = `${song.title} ♫ Koel`
2022-07-25 18:23:30 +00:00
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
2022-04-15 14:24:30 +00:00
if (queueStore.current) {
2022-06-10 10:47:46 +00:00
queueStore.current.playback_state = 'Stopped'
2022-04-15 14:24:30 +00:00
}
2022-06-10 10:47:46 +00:00
song.playback_state = 'Playing'
2022-04-15 14:24:30 +00:00
// Manually set the `src` attribute of the audio to prevent plyr from resetting
// the audio media object and cause our equalizer to malfunction.
2022-07-25 18:23:30 +00:00
this.player.media.src = songStore.getSourceUrl(song)
2022-04-15 14:24:30 +00:00
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
// Fixes #898
if (isAudioContextSupported) {
await audioService.getContext().resume()
}
await this.restart()
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public showNotification (song: Song) {
2022-04-15 14:24:30 +00:00
if (!window.Notification || !preferences.notify) {
return
}
try {
2022-05-14 14:45:48 +00:00
const notification = new window.Notification(`${song.title}`, {
2022-06-10 10:47:46 +00:00
icon: song.album_cover,
body: `${song.album_name} ${song.artist_name}`
2022-04-15 14:24:30 +00:00
})
2022-05-14 14:45:48 +00:00
notification.onclick = () => window.focus()
2022-04-15 14:24:30 +00:00
2022-05-14 14:45:48 +00:00
window.setTimeout(() => notification.close(), 5000)
2022-04-15 14:24:30 +00:00
} catch (e) {
// Notification fails.
// @link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification
2022-07-20 08:00:02 +00:00
logger.error(e)
2022-04-15 14:24:30 +00:00
}
2022-04-30 10:36:35 +00:00
navigator.mediaSession.metadata = new MediaMetadata({
title: song.title,
2022-06-10 10:47:46 +00:00
artist: song.artist_name,
album: song.album_name,
2022-04-30 10:36:35 +00:00
artwork: [
2022-05-14 14:45:48 +00:00
{
2022-06-10 10:47:46 +00:00
src: song.album_cover,
2022-05-14 14:45:48 +00:00
sizes: '256x256',
type: 'image/png'
}
2022-04-30 10:36:35 +00:00
]
})
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public async restart () {
2022-04-15 14:24:30 +00:00
const song = queueStore.current!
this.showNotification(song)
// Record the UNIX timestamp the song starts playing, for scrobbling purpose
2022-06-10 10:47:46 +00:00
song.play_start_time = Math.floor(Date.now() / 1000)
song.play_count_registered = false
2022-04-15 14:24:30 +00:00
socketService.broadcast('SOCKET_SONG', song)
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
this.player.restart()
2022-04-15 14:24:30 +00:00
try {
2022-07-25 18:23:30 +00:00
await this.player.media.play()
2022-04-15 14:24:30 +00:00
} catch (error) {
// convert this into a warning, as an error will cause Cypress to fail the tests entirely
2022-07-20 08:00:02 +00:00
logger.warn(error)
2022-04-15 14:24:30 +00:00
}
2022-07-25 18:23:30 +00:00
}
public get isTranscoding () {
return isMobile.any && preferences.transcodeOnMobile
}
2022-04-15 14:24:30 +00:00
/**
* The next song in the queue.
* If we're in REPEAT_ALL mode and there's no next song, just get the first song.
*/
2022-07-25 18:23:30 +00:00
public get next () {
2022-04-15 14:24:30 +00:00
if (queueStore.next) {
return queueStore.next
}
if (preferences.repeatMode === 'REPEAT_ALL') {
return queueStore.first
}
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* The previous song in the queue.
* If we're in REPEAT_ALL mode and there's no prev song, get the last song.
*/
2022-07-25 18:23:30 +00:00
public get previous () {
2022-04-15 14:24:30 +00:00
if (queueStore.previous) {
return queueStore.previous
}
if (preferences.repeatMode === 'REPEAT_ALL') {
return queueStore.last
}
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* Circle through the repeat mode.
* The selected mode will be stored into local storage as well.
*/
2022-07-25 18:23:30 +00:00
public changeRepeatMode () {
2022-04-15 14:24:30 +00:00
let index = this.repeatModes.indexOf(preferences.repeatMode) + 1
if (index >= this.repeatModes.length) {
index = 0
}
preferences.repeatMode = this.repeatModes[index]
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* Play the prev song in the queue, if one is found.
* If the prev song is not found and the current mode is NO_REPEAT, we stop completely.
*/
2022-07-25 18:23:30 +00:00
public async playPrev () {
2022-04-15 14:24:30 +00:00
// If the song's duration is greater than 5 seconds and we've passed 5 seconds into it,
// restart playing instead.
2022-07-25 18:23:30 +00:00
if (this.player.media.currentTime > 5 && queueStore.current!.length > 5) {
this.player.restart()
2022-04-15 14:24:30 +00:00
return
}
if (!this.previous && preferences.repeatMode === 'NO_REPEAT') {
2022-06-10 10:47:46 +00:00
await this.stop()
2022-04-15 14:24:30 +00:00
} else {
2022-06-10 10:47:46 +00:00
this.previous && await this.play(this.previous)
2022-04-15 14:24:30 +00:00
}
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* Play the next song in the queue, if one is found.
* If the next song is not found and the current mode is NO_REPEAT, we stop completely.
*/
2022-07-25 18:23:30 +00:00
public async playNext () {
2022-04-15 14:24:30 +00:00
if (!this.next && preferences.repeatMode === 'NO_REPEAT') {
2022-06-10 10:47:46 +00:00
await this.stop() // Nothing lasts forever, even cold November rain.
2022-04-15 14:24:30 +00:00
} else {
2022-06-10 10:47:46 +00:00
this.next && await this.play(this.next)
2022-04-15 14:24:30 +00:00
}
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public getVolume () {
return preferences.volume
}
2022-04-15 14:24:30 +00:00
/**
* @param {Number} volume 0-10
* @param {Boolean=true} persist Whether the volume should be saved into local storage
*/
2022-07-25 18:23:30 +00:00
public setVolume (volume: number, persist = true) {
this.player.setVolume(volume)
persist && (preferences.volume = volume)
2022-04-15 14:24:30 +00:00
this.volumeInput.value = String(volume)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public mute () {
2022-04-15 14:24:30 +00:00
this.setVolume(0, false)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public unmute () {
2022-04-30 10:36:35 +00:00
preferences.volume = preferences.volume || DEFAULT_VOLUME_VALUE
2022-04-15 14:24:30 +00:00
this.setVolume(preferences.volume)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public async stop () {
document.title = 'Koel'
2022-07-25 18:23:30 +00:00
this.player.pause()
this.player.seek(0)
2022-04-15 14:24:30 +00:00
if (queueStore.current) {
2022-06-10 10:47:46 +00:00
queueStore.current.playback_state = 'Stopped'
2022-04-15 14:24:30 +00:00
}
2022-04-24 08:50:45 +00:00
socketService.broadcast('SOCKET_PLAYBACK_STOPPED')
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public pause () {
this.player.pause()
2022-06-10 10:47:46 +00:00
queueStore.current!.playback_state = 'Paused'
socketService.broadcast('SOCKET_SONG', queueStore.current)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public async resume () {
2022-04-15 14:24:30 +00:00
try {
2022-07-25 18:23:30 +00:00
await this.player.media.play()
2022-04-15 14:24:30 +00:00
} catch (error) {
2022-07-20 08:00:02 +00:00
logger.error(error)
2022-04-15 14:24:30 +00:00
}
2022-06-10 10:47:46 +00:00
queueStore.current!.playback_state = 'Playing'
socketService.broadcast('SOCKET_SONG', queueStore.current)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public async toggle () {
2022-04-15 14:24:30 +00:00
if (!queueStore.current) {
await this.playFirstInQueue()
return
}
2022-06-10 10:47:46 +00:00
if (queueStore.current.playback_state !== 'Playing') {
2022-04-15 14:24:30 +00:00
await this.resume()
return
}
this.pause()
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
/**
* Queue up songs (replace them into the queue) and start playing right away.
*
* @param {?Song[]} songs An array of song objects. Defaults to all songs if null.
* @param {Boolean=false} shuffled Whether to shuffle the songs before playing.
*/
2022-08-01 11:40:52 +00:00
public async queueAndPlay (songs: Song | Song[], shuffled = false) {
songs = arrayify(songs)
2022-04-15 14:24:30 +00:00
if (shuffled) {
songs = shuffle(songs)
}
2022-06-10 10:47:46 +00:00
await this.stop()
2022-04-15 14:24:30 +00:00
queueStore.replaceQueueWith(songs)
await this.play(queueStore.first)
2022-07-25 18:23:30 +00:00
}
2022-04-15 14:24:30 +00:00
2022-07-25 18:23:30 +00:00
public async playFirstInQueue () {
2022-06-10 10:47:46 +00:00
queueStore.all.length && await this.play(queueStore.first)
2022-04-15 14:24:30 +00:00
}
private setMediaSessionActionHandlers () {
if (!navigator.mediaSession) {
return
}
navigator.mediaSession.setActionHandler('play', () => this.resume())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () => this.playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext())
}
private listenToMediaEvents (mediaElement: HTMLMediaElement) {
mediaElement.addEventListener('error', () => this.playNext(), true)
mediaElement.addEventListener('ended', () => {
if (commonStore.state.use_last_fm && userStore.current.preferences!.lastfm_session_key) {
songStore.scrobble(queueStore.current!)
}
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
})
let timeUpdateHandler = () => {
const currentSong = queueStore.current
if (!currentSong) return
if (!currentSong.play_count_registered && !this.isTranscoding) {
// if we've passed 25% of the song, it's safe to say the song has been "played".
// Refer to https://github.com/koel/koel/issues/1087
if (!mediaElement.duration || mediaElement.currentTime * 4 >= mediaElement.duration) {
this.registerPlay(currentSong)
}
}
const nextSong = queueStore.next
if (!nextSong || nextSong.preloaded || this.isTranscoding) {
return
}
if (mediaElement.duration && mediaElement.currentTime + PRELOAD_BUFFER > mediaElement.duration) {
this.preload(nextSong)
}
}
if (process.env.NODE_ENV !== 'test') {
timeUpdateHandler = throttle(timeUpdateHandler, 1000)
}
mediaElement.addEventListener('timeupdate', timeUpdateHandler)
}
2022-04-15 14:24:30 +00:00
}
2022-07-25 18:23:30 +00:00
export const playbackService = new PlaybackService()