mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
chore: improve playback and related services (#1570)
This commit is contained in:
parent
d7a0b69706
commit
bd6617dc17
19 changed files with 256 additions and 158 deletions
|
@ -26,9 +26,9 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, nextTick, onMounted, provide, ref, watch } from 'vue'
|
||||
import { eventBus, hideOverlay, requireInjection, showOverlay } from '@/utils'
|
||||
import { hideOverlay, requireInjection, showOverlay } from '@/utils'
|
||||
import { commonStore, preferenceStore as preferences, queueStore } from '@/stores'
|
||||
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
|
||||
import { authService, socketListener, socketService, uploadService } from '@/services'
|
||||
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
|
||||
import { useNetworkStatus } from '@/composables'
|
||||
|
||||
|
@ -97,7 +97,6 @@ const init = async () => {
|
|||
await commonStore.init()
|
||||
await nextTick()
|
||||
|
||||
playbackService.init()
|
||||
await requestNotificationPermission()
|
||||
|
||||
window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
|
||||
|
@ -110,9 +109,6 @@ const init = async () => {
|
|||
await socketService.init() && socketListener.listen()
|
||||
|
||||
hideOverlay()
|
||||
|
||||
// Let all other components know we're ready.
|
||||
eventBus.emit('KOEL_READY')
|
||||
} catch (err) {
|
||||
authenticated.value = false
|
||||
throw err
|
||||
|
@ -125,7 +121,7 @@ const onDragOver = (e: DragEvent) => {
|
|||
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && router.$currentRoute.value.screen !== 'Upload'
|
||||
}
|
||||
|
||||
watch(() => queueStore.current, song => (currentSong.value = song))
|
||||
watch(() => queueStore.current, song => song && (currentSong.value = song))
|
||||
|
||||
const onDragEnd = () => (showDropZone.value = false)
|
||||
const onDrop = () => (showDropZone.value = false)
|
||||
|
|
|
@ -7,7 +7,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// can't be scoped as it would be overridden by the plyr css
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { waitFor } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { playbackService, volumeManager } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import Component from './index.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('initializes playback services', async () => {
|
||||
const initPlaybackMock = this.mock(playbackService, 'init')
|
||||
const initVolumeMock = this.mock(volumeManager, 'init')
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
this.render(Component)
|
||||
preferenceStore.initialized.value = true
|
||||
|
||||
await waitFor(() => {
|
||||
expect(initPlaybackMock).toHaveBeenCalled()
|
||||
expect(initVolumeMock).toHaveBeenCalled()
|
||||
expect(emitMock).toHaveBeenCalledWith('INIT_EQUALIZER')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -11,9 +11,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { eventBus, requireInjection } from '@/utils'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { eventBus, isAudioContextSupported, requireInjection } from '@/utils'
|
||||
import { CurrentSongKey } from '@/symbols'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import { audioService, playbackService, volumeManager } from '@/services'
|
||||
|
||||
import AudioPlayer from '@/components/layout/app-footer/AudioPlayer.vue'
|
||||
import SongInfo from '@/components/layout/app-footer/FooterSongInfo.vue'
|
||||
|
@ -25,6 +27,28 @@ const song = requireInjection(CurrentSongKey, ref(null))
|
|||
const requestContextMenu = (event: MouseEvent) => {
|
||||
song.value && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||
}
|
||||
|
||||
const initPlaybackRelatedServices = async () => {
|
||||
const plyrWrapper = document.querySelector<HTMLElement>('.plyr')
|
||||
const volumeInput = document.querySelector<HTMLInputElement>('#volumeInput')
|
||||
|
||||
if (!plyrWrapper || !volumeInput) {
|
||||
await nextTick()
|
||||
await initPlaybackRelatedServices()
|
||||
return
|
||||
}
|
||||
|
||||
playbackService.init(plyrWrapper)
|
||||
volumeManager.init(volumeInput)
|
||||
isAudioContextSupported && audioService.init(playbackService.player.media)
|
||||
|
||||
eventBus.emit('INIT_EQUALIZER')
|
||||
}
|
||||
|
||||
watch(preferenceStore.initialized, async initialized => {
|
||||
if (!initialized) return
|
||||
await initPlaybackRelatedServices()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { expect, it, vi } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import SupportKoel from './SupportKoel.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach () {
|
||||
super.beforeEach(() => vi.useFakeTimers());
|
||||
super.beforeEach(() => vi.useFakeTimers())
|
||||
}
|
||||
|
||||
protected afterEach () {
|
||||
|
@ -18,13 +17,13 @@ new class extends UnitTestCase {
|
|||
}
|
||||
|
||||
private async renderComponent () {
|
||||
const result = this.render(SupportKoel)
|
||||
eventBus.emit('KOEL_READY')
|
||||
preferenceStore.initialized.value = true
|
||||
const rendered = this.render(SupportKoel)
|
||||
|
||||
vi.advanceTimersByTime(30 * 60 * 1000)
|
||||
await this.tick()
|
||||
|
||||
return result
|
||||
return rendered
|
||||
}
|
||||
|
||||
protected test () {
|
||||
|
@ -34,6 +33,7 @@ new class extends UnitTestCase {
|
|||
|
||||
it('does not show if user so demands', async () => {
|
||||
preferenceStore.state.supportBarNoBugging = true
|
||||
preferenceStore.initialized.value = true
|
||||
expect((await this.renderComponent()).queryByTestId('support-bar')).toBeNull()
|
||||
})
|
||||
|
||||
|
|
|
@ -16,31 +16,26 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
const delayUntilShow = 30 * 60 * 1000
|
||||
let timeoutHandle = 0
|
||||
const delayUntilShow = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
const shown = ref(false)
|
||||
const noBugging = toRef(preferenceStore.state, 'supportBarNoBugging')
|
||||
|
||||
const canNag = computed(() => !isMobile.any && !noBugging.value)
|
||||
|
||||
const setUpShowBarTimeout = () => (timeoutHandle = window.setTimeout(() => (shown.value = true), delayUntilShow))
|
||||
|
||||
const close = () => {
|
||||
shown.value = false
|
||||
window.clearTimeout(timeoutHandle)
|
||||
}
|
||||
const setUpShowBarTimeout = () => setTimeout(() => (shown.value = true), delayUntilShow)
|
||||
const close = () => shown.value = false
|
||||
|
||||
const stopBugging = () => {
|
||||
preferenceStore.set('supportBarNoBugging', true)
|
||||
close()
|
||||
}
|
||||
|
||||
eventBus.on('KOEL_READY', () => canNag.value && setUpShowBarTimeout())
|
||||
watch(preferenceStore.initialized, initialized => {
|
||||
if (!initialized) return
|
||||
if (preferenceStore.state.supportBarNoBugging || isMobile.any) return
|
||||
setUpShowBarTimeout()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,32 +1,42 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import { playbackService, socketService } from '@/services'
|
||||
import { socketService, volumeManager } from '@/services'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import Volume from './Volume.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach (cb?: Closure) {
|
||||
super.beforeEach(() => {
|
||||
preferenceStore.state.volume = 5
|
||||
volumeManager.init(document.createElement('input'))
|
||||
})
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('mutes and unmutes', async () => {
|
||||
const muteMock = this.mock(playbackService, 'mute')
|
||||
const unmuteMock = this.mock(playbackService, 'unmute')
|
||||
const { getByRole } = this.render(Volume)
|
||||
const { getByTitle, html } = this.render(Volume)
|
||||
expect(html()).toMatchSnapshot()
|
||||
expect(volumeManager.volume.value).toEqual(5)
|
||||
|
||||
await fireEvent.click(getByRole('button'))
|
||||
expect(muteMock).toHaveBeenCalledOnce()
|
||||
await fireEvent.click(getByTitle('Mute'))
|
||||
expect(html()).toMatchSnapshot()
|
||||
expect(volumeManager.volume.value).toEqual(0)
|
||||
|
||||
await fireEvent.click(getByRole('button'))
|
||||
expect(unmuteMock).toHaveBeenCalledOnce()
|
||||
await fireEvent.click(getByTitle('Unmute'))
|
||||
expect(html()).toMatchSnapshot()
|
||||
expect(volumeManager.volume.value).toEqual(5)
|
||||
})
|
||||
|
||||
it('sets and broadcasts volume', async () => {
|
||||
const setVolumeMock = this.mock(playbackService, 'setVolume')
|
||||
const setMock = this.mock(volumeManager, 'set')
|
||||
const broadCastMock = this.mock(socketService, 'broadcast')
|
||||
const { getByRole } = this.render(Volume)
|
||||
|
||||
await fireEvent.update(getByRole('slider'), '4.2')
|
||||
await fireEvent.change(getByRole('slider'))
|
||||
|
||||
expect(setVolumeMock).toHaveBeenCalledWith(4.2)
|
||||
expect(setMock).toHaveBeenCalledWith(4.2)
|
||||
expect(broadCastMock).toHaveBeenCalledWith('SOCKET_VOLUME_CHANGED', 4.2)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -38,30 +38,20 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeMute } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
import { playbackService, socketService } from '@/services'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
import { eventBus } from '@/utils'
|
||||
import { computed } from 'vue'
|
||||
import { socketService, volumeManager } from '@/services'
|
||||
|
||||
const level = ref<'muted' | 'discreet' | 'loud'>()
|
||||
const volume = volumeManager.volume
|
||||
|
||||
const mute = () => {
|
||||
playbackService.mute()
|
||||
level.value = 'muted'
|
||||
}
|
||||
const level = computed(() => {
|
||||
if (volume.value === 0) return 'muted'
|
||||
if (volume.value < 3) return 'discreet'
|
||||
return 'loud'
|
||||
})
|
||||
|
||||
const unmute = () => {
|
||||
playbackService.unmute()
|
||||
level.value = preferences.volume < 3 ? 'discreet' : 'loud'
|
||||
}
|
||||
|
||||
const setVolume = (e: InputEvent) => {
|
||||
const volume = parseFloat((e.target as HTMLInputElement).value)
|
||||
playbackService.setVolume(volume)
|
||||
setLevel(volume)
|
||||
}
|
||||
|
||||
const setLevel = (volume: number) => (level.value = volume === 0 ? 'muted' : volume < 3 ? 'discreet' : 'loud')
|
||||
const mute = () => volumeManager.mute()
|
||||
const unmute = () => volumeManager.unmute()
|
||||
const setVolume = (e: InputEvent) => volumeManager.set(parseFloat((e.target as HTMLInputElement).value))
|
||||
|
||||
/**
|
||||
* Broadcast the volume changed event to remote controller.
|
||||
|
@ -69,8 +59,6 @@ const setLevel = (volume: number) => (level.value = volume === 0 ? 'muted' : vol
|
|||
const broadcastVolume = (e: InputEvent) => {
|
||||
socketService.broadcast('SOCKET_VOLUME_CHANGED', parseFloat((e.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
eventBus.on('KOEL_READY', () => setLevel(preferences.volume))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`mutes and unmutes 1`] = `<span id="volume" class="volume loud"><span role="button" tabindex="0" title="Unmute" style="display: none;"><br data-testid="icon" icon="[object Object]" fixed-width=""></span><span role="button" tabindex="0" title="Mute"><br data-testid="icon" icon="[object Object]" fixed-width=""></span><input id="volumeInput" class="plyr__volume" max="10" role="slider" step="0.1" title="Volume" type="range"></span>`;
|
||||
|
||||
exports[`mutes and unmutes 2`] = `<span id="volume" class="volume muted"><span role="button" tabindex="0" title="Unmute" style=""><br data-testid="icon" icon="[object Object]" fixed-width=""></span><span role="button" tabindex="0" title="Mute" style="display: none;"><br data-testid="icon" icon="[object Object]" fixed-width=""></span><input id="volumeInput" class="plyr__volume" max="10" role="slider" step="0.1" title="Volume" type="range"></span>`;
|
||||
|
||||
exports[`mutes and unmutes 3`] = `<span id="volume" class="volume loud"><span role="button" tabindex="0" title="Unmute" style="display: none;"><br data-testid="icon" icon="[object Object]" fixed-width=""></span><span role="button" tabindex="0" title="Mute" style=""><br data-testid="icon" icon="[object Object]" fixed-width=""></span><input id="volumeInput" class="plyr__volume" max="10" role="slider" step="0.1" title="Volume" type="range"></span>`;
|
|
@ -1,5 +1,4 @@
|
|||
export type EventName =
|
||||
'KOEL_READY'
|
||||
| 'LOG_OUT'
|
||||
| 'TOGGLE_SIDEBAR'
|
||||
| 'SHOW_OVERLAY'
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
export const audioService = {
|
||||
unlocked: false,
|
||||
context: null as unknown as AudioContext,
|
||||
source: null as unknown as MediaElementAudioSourceNode,
|
||||
element: null as unknown as HTMLMediaElement,
|
||||
|
||||
init (element: HTMLMediaElement) {
|
||||
const AudioContext = window.AudioContext ||
|
||||
window.webkitAudioContext ||
|
||||
window.mozAudioContext ||
|
||||
window.oAudioContext ||
|
||||
window.msAudioContext
|
||||
|
||||
this.context = new AudioContext()
|
||||
this.source = this.context.createMediaElementSource(element)
|
||||
this.element = element
|
||||
|
||||
this.unlockAudioContext()
|
||||
},
|
||||
|
||||
getContext () {
|
||||
|
@ -25,5 +22,26 @@ export const audioService = {
|
|||
|
||||
getElement () {
|
||||
return this.element
|
||||
},
|
||||
|
||||
/**
|
||||
* Attempt to unlock the audio context on mobile devices by creating and playing a silent buffer upon the
|
||||
* first user interaction.
|
||||
*/
|
||||
unlockAudioContext () {
|
||||
['touchend', 'touchstart', 'click'].forEach(event => {
|
||||
document.addEventListener(event, () => {
|
||||
if (this.unlocked) return
|
||||
|
||||
const source = this.context.createBufferSource()
|
||||
source.buffer = this.context.createBuffer(1, 1, 22050)
|
||||
source.connect(this.context.destination)
|
||||
source.start(0)
|
||||
|
||||
this.unlocked = true
|
||||
}, {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,3 +10,4 @@ export * from './authService'
|
|||
export * from './mediaInfoService'
|
||||
export * from './cache'
|
||||
export * from './socketListener'
|
||||
export * from './volumeManager'
|
||||
|
|
|
@ -23,14 +23,6 @@ new class extends UnitTestCase {
|
|||
<div class="plyr">
|
||||
<audio crossorigin="anonymous" controls/>
|
||||
</div>
|
||||
<input
|
||||
class="plyr__volume"
|
||||
id="volumeRange"
|
||||
max="10"
|
||||
step="0.1"
|
||||
title="Volume"
|
||||
type="range"
|
||||
>
|
||||
`
|
||||
|
||||
window.AudioContext = vi.fn().mockImplementation(() => ({
|
||||
|
@ -55,10 +47,10 @@ new class extends UnitTestCase {
|
|||
it('only initializes once', () => {
|
||||
const spy = vi.spyOn(plyr, 'setup')
|
||||
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
expect(spy).toHaveBeenCalled()
|
||||
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
@ -76,7 +68,7 @@ new class extends UnitTestCase {
|
|||
}))
|
||||
|
||||
this.setReadOnlyProperty(playbackService, 'isTranscoding', isTranscoding)
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const mediaElement = playbackService.player.media
|
||||
|
||||
|
@ -91,7 +83,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays next song if current song is errored', () => {
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
const playNextMock = this.mock(playbackService, 'playNext')
|
||||
playbackService.player!.media.dispatchEvent(new Event('error'))
|
||||
expect(playNextMock).toHaveBeenCalled()
|
||||
|
@ -105,7 +97,7 @@ new class extends UnitTestCase {
|
|||
}
|
||||
}))
|
||||
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
const scrobbleMock = this.mock(songStore, 'scrobble')
|
||||
playbackService.player!.media.dispatchEvent(new Event('ended'))
|
||||
expect(scrobbleMock).toHaveBeenCalled()
|
||||
|
@ -116,7 +108,7 @@ new class extends UnitTestCase {
|
|||
(repeatMode, restartCalls, playNextCalls) => {
|
||||
commonStore.state.use_last_fm = false // so that no scrobbling is made unnecessarily
|
||||
preferences.repeatMode = repeatMode
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
const restartMock = this.mock(playbackService, 'restart')
|
||||
const playNextMock = this.mock(playbackService, 'playNext')
|
||||
|
||||
|
@ -138,7 +130,7 @@ new class extends UnitTestCase {
|
|||
|
||||
this.setReadOnlyProperty(queueStore, 'next', factory<Song>('song', { preloaded }))
|
||||
this.setReadOnlyProperty(playbackService, 'isTranscoding', isTranscoding)
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const mediaElement = playbackService.player!.media
|
||||
|
||||
|
@ -297,7 +289,7 @@ new class extends UnitTestCase {
|
|||
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
|
||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||
|
||||
playbackService.init()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
await playbackService.resume()
|
||||
|
||||
expect(queueStore.current?.playback_state).toEqual('Playing')
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import isMobile from 'ismobilejs'
|
||||
import plyr from 'plyr'
|
||||
import { watch } from 'vue'
|
||||
import { shuffle, throttle } from 'lodash'
|
||||
|
||||
import {
|
||||
|
@ -11,49 +12,30 @@ import {
|
|||
userStore
|
||||
} from '@/stores'
|
||||
|
||||
import { arrayify, eventBus, isAudioContextSupported, logger } from '@/utils'
|
||||
import { audioService, socketService } from '@/services'
|
||||
import { arrayify, isAudioContextSupported, logger } from '@/utils'
|
||||
import { audioService, socketService, volumeManager } from '@/services'
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
class PlaybackService {
|
||||
// @ts-ignore
|
||||
public player: Plyr
|
||||
// @ts-ignore
|
||||
private volumeInput: HTMLInputElement
|
||||
public player!: Plyr
|
||||
private repeatModes: RepeatMode[] = ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']
|
||||
private initialized = false
|
||||
|
||||
public init () {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
public init (plyrWrapper: HTMLElement) {
|
||||
if (this.initialized) return
|
||||
|
||||
this.player = plyr.setup(plyrWrapper, { controls: [] })[0]
|
||||
|
||||
this.listenToMediaEvents(this.player.media)
|
||||
this.setMediaSessionActionHandlers()
|
||||
|
||||
watch(volumeManager.volume, volume => this.player.setVolume(volume), { immediate: true })
|
||||
|
||||
this.initialized = true
|
||||
|
||||
this.player = plyr.setup('.plyr', {
|
||||
controls: []
|
||||
})[0]
|
||||
|
||||
this.volumeInput = document.querySelector<HTMLInputElement>(VOLUME_INPUT_SELECTOR)!
|
||||
this.listenToMediaEvents(this.player.media)
|
||||
|
||||
if (isAudioContextSupported) {
|
||||
try {
|
||||
this.setVolume(preferences.volume)
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
audioService.init(this.player.media)
|
||||
eventBus.emit('INIT_EQUALIZER')
|
||||
}
|
||||
|
||||
this.setMediaSessionActionHandlers()
|
||||
}
|
||||
|
||||
public registerPlay (song: Song) {
|
||||
|
@ -245,29 +227,6 @@ class PlaybackService {
|
|||
}
|
||||
}
|
||||
|
||||
public getVolume () {
|
||||
return preferences.volume
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} volume 0-10
|
||||
* @param {Boolean=true} persist Whether the volume should be saved into local storage
|
||||
*/
|
||||
public setVolume (volume: number, persist = true) {
|
||||
this.player.setVolume(volume)
|
||||
persist && (preferences.volume = volume)
|
||||
this.volumeInput.value = String(volume)
|
||||
}
|
||||
|
||||
public mute () {
|
||||
this.setVolume(0, false)
|
||||
}
|
||||
|
||||
public unmute () {
|
||||
preferences.volume = preferences.volume || DEFAULT_VOLUME_VALUE
|
||||
this.setVolume(preferences.volume)
|
||||
}
|
||||
|
||||
public async stop () {
|
||||
document.title = 'Koel'
|
||||
this.player.pause()
|
||||
|
@ -344,10 +303,10 @@ class PlaybackService {
|
|||
navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext())
|
||||
}
|
||||
|
||||
private listenToMediaEvents (mediaElement: HTMLMediaElement) {
|
||||
mediaElement.addEventListener('error', () => this.playNext(), true)
|
||||
private listenToMediaEvents (media: HTMLMediaElement) {
|
||||
media.addEventListener('error', () => this.playNext(), true)
|
||||
|
||||
mediaElement.addEventListener('ended', () => {
|
||||
media.addEventListener('ended', () => {
|
||||
if (commonStore.state.use_last_fm && userStore.current.preferences!.lastfm_session_key) {
|
||||
songStore.scrobble(queueStore.current!)
|
||||
}
|
||||
|
@ -363,7 +322,7 @@ class PlaybackService {
|
|||
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) {
|
||||
if (!media.duration || media.currentTime * 4 >= media.duration) {
|
||||
this.registerPlay(currentSong)
|
||||
}
|
||||
}
|
||||
|
@ -374,7 +333,7 @@ class PlaybackService {
|
|||
return
|
||||
}
|
||||
|
||||
if (mediaElement.duration && mediaElement.currentTime + PRELOAD_BUFFER > mediaElement.duration) {
|
||||
if (media.duration && media.currentTime + PRELOAD_BUFFER > media.duration) {
|
||||
this.preload(nextSong)
|
||||
}
|
||||
}
|
||||
|
@ -383,7 +342,7 @@ class PlaybackService {
|
|||
timeUpdateHandler = throttle(timeUpdateHandler, 1000)
|
||||
}
|
||||
|
||||
mediaElement.addEventListener('timeupdate', timeUpdateHandler)
|
||||
media.addEventListener('timeupdate', timeUpdateHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { playbackService, socketService } from '@/services'
|
||||
import { playbackService, socketService, volumeManager } from '@/services'
|
||||
import { favoriteStore, queueStore } from '@/stores'
|
||||
|
||||
export const socketListener = {
|
||||
|
@ -10,11 +10,11 @@ export const socketListener = {
|
|||
.listen('SOCKET_GET_STATUS', () => {
|
||||
socketService.broadcast('SOCKET_STATUS', {
|
||||
song: queueStore.current,
|
||||
volume: playbackService.getVolume()
|
||||
volume: volumeManager.get()
|
||||
})
|
||||
})
|
||||
.listen('SOCKET_GET_CURRENT_SONG', () => socketService.broadcast('SOCKET_SONG', queueStore.current))
|
||||
.listen('SOCKET_SET_VOLUME', (volume: number) => playbackService.setVolume(volume))
|
||||
.listen('SOCKET_SET_VOLUME', (volume: number) => volumeManager.set(volume))
|
||||
.listen('SOCKET_TOGGLE_FAVORITE', () => queueStore.current && favoriteStore.toggleOne(queueStore.current))
|
||||
}
|
||||
}
|
||||
|
|
43
resources/assets/js/services/volumeManager.spec.ts
Normal file
43
resources/assets/js/services/volumeManager.spec.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { volumeManager } from '@/services/volumeManager'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach (cb?: Closure) {
|
||||
super.beforeEach(() => {
|
||||
preferenceStore.state.volume = 5
|
||||
input = document.createElement('input')
|
||||
volumeManager.init(input)
|
||||
})
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('gets volume', () => expect(volumeManager.get()).toEqual(5))
|
||||
|
||||
it('sets volume', () => {
|
||||
volumeManager.set(4.2)
|
||||
expect(volumeManager.volume.value).toEqual(4.2)
|
||||
expect(input.value).toEqual('4.2')
|
||||
expect(preferenceStore.state.volume).toEqual(4.2)
|
||||
})
|
||||
|
||||
it('mutes', () => {
|
||||
volumeManager.mute()
|
||||
expect(volumeManager.volume.value).toEqual(0)
|
||||
expect(input.value).toEqual('0')
|
||||
|
||||
// muting should not persist
|
||||
expect(preferenceStore.state.volume).toEqual(5)
|
||||
})
|
||||
|
||||
it('unmutes', () => {
|
||||
preferenceStore.state.volume = 7
|
||||
volumeManager.unmute()
|
||||
expect(volumeManager.volume.value).toEqual(7)
|
||||
expect(input.value).toEqual('7')
|
||||
})
|
||||
}
|
||||
}
|
35
resources/assets/js/services/volumeManager.ts
Normal file
35
resources/assets/js/services/volumeManager.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { ref } from 'vue'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
export class VolumeManager {
|
||||
private input!: HTMLInputElement
|
||||
public volume = ref(0)
|
||||
|
||||
public init (input: HTMLInputElement) {
|
||||
this.input = input
|
||||
this.set(preferenceStore.state.volume)
|
||||
}
|
||||
|
||||
public get () {
|
||||
return this.volume.value
|
||||
}
|
||||
|
||||
public set (volume: number, persist = true) {
|
||||
if (persist) {
|
||||
preferenceStore.state.volume = volume
|
||||
}
|
||||
|
||||
this.volume.value = volume
|
||||
this.input.value = String(volume)
|
||||
}
|
||||
|
||||
public mute () {
|
||||
this.set(0, false)
|
||||
}
|
||||
|
||||
public unmute () {
|
||||
this.set(preferenceStore.state.volume)
|
||||
}
|
||||
}
|
||||
|
||||
export const volumeManager = new VolumeManager()
|
|
@ -1,4 +1,4 @@
|
|||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { localStorageService } from '@/services'
|
||||
|
||||
|
@ -20,6 +20,7 @@ interface Preferences extends Record<string, any> {
|
|||
|
||||
const preferenceStore = {
|
||||
storeKey: '',
|
||||
initialized: ref(false),
|
||||
|
||||
state: reactive<Preferences>({
|
||||
volume: 7,
|
||||
|
@ -45,6 +46,8 @@ const preferenceStore = {
|
|||
this.storeKey = `preferences_${initUser.id}`
|
||||
Object.assign(this.state, localStorageService.get(this.storeKey, this.state))
|
||||
this.setupProxy()
|
||||
|
||||
this.initialized.value = true
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -10,18 +10,18 @@ new class extends UnitTestCase {
|
|||
protected test () {
|
||||
it('listens on a single event', () => {
|
||||
const mock = vi.fn()
|
||||
eventBus.on('KOEL_READY', mock)
|
||||
eventBus.on('SHOW_OVERLAY', mock)
|
||||
|
||||
eventBus.emit('KOEL_READY')
|
||||
eventBus.emit('SHOW_OVERLAY')
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('listens with parameters', () => {
|
||||
const mock = vi.fn()
|
||||
eventBus.on('KOEL_READY', mock)
|
||||
eventBus.on('SHOW_OVERLAY', mock)
|
||||
|
||||
eventBus.emit('KOEL_READY', 'foo', 'bar')
|
||||
eventBus.emit('SHOW_OVERLAY', 'foo', 'bar')
|
||||
|
||||
expect(mock).toHaveBeenNthCalledWith(1, 'foo', 'bar')
|
||||
})
|
||||
|
@ -31,11 +31,11 @@ new class extends UnitTestCase {
|
|||
const mock2 = vi.fn()
|
||||
|
||||
eventBus.on({
|
||||
KOEL_READY: mock1,
|
||||
SHOW_OVERLAY: mock1,
|
||||
MODAL_SHOW_ABOUT_KOEL: mock2
|
||||
})
|
||||
|
||||
eventBus.emit('KOEL_READY')
|
||||
eventBus.emit('SHOW_OVERLAY')
|
||||
expect(mock1).toHaveBeenCalledOnce()
|
||||
|
||||
eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
|
||||
|
@ -45,10 +45,10 @@ new class extends UnitTestCase {
|
|||
it('queue up listeners on same event', () => {
|
||||
const mock1 = vi.fn()
|
||||
const mock2 = vi.fn()
|
||||
eventBus.on('KOEL_READY', mock1)
|
||||
eventBus.on('KOEL_READY', mock2)
|
||||
eventBus.on('SHOW_OVERLAY', mock1)
|
||||
eventBus.on('SHOW_OVERLAY', mock2)
|
||||
|
||||
eventBus.emit('KOEL_READY')
|
||||
eventBus.emit('SHOW_OVERLAY')
|
||||
|
||||
expect(mock1).toHaveBeenCalledOnce()
|
||||
expect(mock2).toHaveBeenCalledOnce()
|
||||
|
|
Loading…
Reference in a new issue