From cec34d49fa14ec27876435f543a13f68f931713e Mon Sep 17 00:00:00 2001 From: wvffle Date: Sun, 3 Jul 2022 18:12:09 +0000 Subject: [PATCH] Rewrite player component to script setup --- front/package.json | 1 + front/src/App.vue | 56 +- front/src/components/audio/Player.vue | 1009 +++++++---------- front/src/composables/audio/useSoundCache.ts | 36 + .../src/composables/audio/useTrackSources.ts | 51 + front/src/composables/usePlayer.ts | 46 +- front/src/composables/useQueue.ts | 21 +- front/src/init/howler.ts | 17 + front/src/init/mediaSession.ts | 45 + front/src/init/webSocket.ts | 53 +- front/src/shims-vuex.d.ts | 3 +- front/src/store/ui.ts | 2 +- front/src/types.ts | 10 +- front/src/utils/index.ts | 3 +- front/yarn.lock | 5 + 15 files changed, 678 insertions(+), 680 deletions(-) create mode 100644 front/src/composables/audio/useSoundCache.ts create mode 100644 front/src/composables/audio/useTrackSources.ts create mode 100644 front/src/init/howler.ts create mode 100644 front/src/init/mediaSession.ts diff --git a/front/package.json b/front/package.json index d0652084a..1f824f62b 100644 --- a/front/package.json +++ b/front/package.json @@ -52,6 +52,7 @@ }, "devDependencies": { "@types/dompurify": "^2.3.3", + "@types/howler": "^2.2.7", "@types/jest": "28.1.3", "@types/jquery": "3.5.14", "@types/lodash-es": "4.17.6", diff --git a/front/src/App.vue b/front/src/App.vue index f1e82687d..056f69a1a 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -12,22 +12,15 @@ import ReportModal from '~/components/moderation/ReportModal.vue' import { useIntervalFn, useToggle, useWindowSize } from '@vueuse/core' import { computed, nextTick, onMounted, ref, watchEffect } from 'vue' -import { - ListenWSEvent, - PendingReviewEditsWSEvent, - PendingReviewReportsWSEvent, - PendingReviewRequestsWSEvent, - Track -} from '~/types' -import useWebSocketHandler from '~/composables/useWebSocketHandler' -import { CLIENT_RADIOS } from '~/utils/clientRadios' +import { Track } from '~/types' import onKeyboardShortcut from '~/composables/onKeyboardShortcut' +import useQueue from '~/composables/useQueue' import { useStore } from '~/store' const store = useStore() // Tracks -const currentTrack = computed(() => store.getters['queue/currentTrack']) +const { currentTrack } = useQueue() const getTrackInformationText = (track: Track | undefined) => { if (!track) { return null @@ -60,49 +53,6 @@ onMounted(async () => { document.getElementById('fake-content')?.classList.add('loaded') }) -// WebSocket handlers -useWebSocketHandler('inbox.item_added', () => { - store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) -}) - -useWebSocketHandler('mutation.created', (event) => { - store.commit('ui/incrementNotifications', { - type: 'pendingReviewEdits', - value: (event as PendingReviewEditsWSEvent).pending_review_count - }) -}) - -useWebSocketHandler('mutation.updated', (event) => { - store.commit('ui/incrementNotifications', { - type: 'pendingReviewEdits', - value: (event as PendingReviewEditsWSEvent).pending_review_count - }) -}) - -useWebSocketHandler('report.created', (event) => { - store.commit('ui/incrementNotifications', { - type: 'pendingReviewReports', - value: (event as PendingReviewReportsWSEvent).unresolved_count - }) -}) - -useWebSocketHandler('user_request.created', (event) => { - store.commit('ui/incrementNotifications', { - type: 'pendingReviewRequests', - value: (event as PendingReviewRequestsWSEvent).pending_count - }) -}) - -useWebSocketHandler('Listen', (event) => { - if (store.state.radios.current && store.state.radios.running) { - const current = store.state.radios.current - - if (current?.clientOnly) { - CLIENT_RADIOS[current.type].handleListen(current, event as ListenWSEvent, store) - } - } -}) - // Time ago // TODO (wvffle): Migrate to useTimeAgo useIntervalFn(() => { diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 047d5b72a..6b58d79ca 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,3 +1,393 @@ + + - - diff --git a/front/src/composables/audio/useSoundCache.ts b/front/src/composables/audio/useSoundCache.ts new file mode 100644 index 000000000..f414121ed --- /dev/null +++ b/front/src/composables/audio/useSoundCache.ts @@ -0,0 +1,36 @@ +import { MaybeRef } from "@vueuse/core" +import { Howl } from "howler" +import { sortBy } from "lodash-es" +import { reactive, watchEffect, ref, unref } from "vue" + +export interface CachedSound { + id: string + date: Date + sound: Howl +} + +export default (maxPreloaded: MaybeRef) => { + const soundCache = reactive(new Map()) + const cleaningCache = ref(false) + + watchEffect(() => { + let toRemove = soundCache.size - unref(maxPreloaded) + + if (toRemove > 0 && !cleaningCache.value) { + cleaningCache.value = true + + const excess = sortBy(soundCache.values(), [(cached: CachedSound) => cached.date]) + // TODO (wvffle): Check if works + .slice(0, toRemove) as unknown as CachedSound[] + + for (const cached of excess) { + soundCache.delete(cached.id) + cached.sound.unload() + } + + cleaningCache.value = false + } + }) + + return soundCache +} \ No newline at end of file diff --git a/front/src/composables/audio/useTrackSources.ts b/front/src/composables/audio/useTrackSources.ts new file mode 100644 index 000000000..f360bfbda --- /dev/null +++ b/front/src/composables/audio/useTrackSources.ts @@ -0,0 +1,51 @@ +import { Track } from "~/types" +import { useStore } from '~/store' +import updateQueryString from '~/composables/updateQueryString' + +export interface TrackSource { + url: string + type: string +} + +export default (trackData: Track): TrackSource[] => { + const store = useStore() + const audio = document.createElement('audio') + + const allowed = ['probably', 'maybe'] + + const sources = trackData.uploads + .filter(upload => { + const canPlay = audio.canPlayType(upload.mimetype) + return allowed.indexOf(canPlay) > -1 + }) + .map(upload => ({ + type: upload.extension, + url: store.getters['instance/absoluteUrl'](upload.listen_url) + })) + + // We always add a transcoded MP3 src at the end + // because transcoding is expensive, but we want browsers that do + // not support other codecs to be able to play it :) + sources.push({ + type: 'mp3', + url: updateQueryString( + store.getters['instance/absoluteUrl'](trackData.listen_url), + 'to', + 'mp3' + ) + }) + + const token = store.state.auth.scopedTokens.listen + if (store.state.auth.authenticated && token !== null) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + return sources.map(source => ({ + ...source, + url: updateQueryString(source.url, 'token', token) + })) + } + + return sources +} \ No newline at end of file diff --git a/front/src/composables/usePlayer.ts b/front/src/composables/usePlayer.ts index 4791efcc4..1968f5ea5 100644 --- a/front/src/composables/usePlayer.ts +++ b/front/src/composables/usePlayer.ts @@ -1,28 +1,62 @@ -import { useStore } from "~/store" -import { computed } from "vue" +import { computed, watchEffect } from "vue" +import { Howler } from 'howler' +import useQueue from '~/composables/useQueue' +import toLinearVolumeScale from '~/composables/audio/toLinearVolumeScale' +import store from "~/store" export default () => { - const store = useStore() const looping = computed(() => store.state.player.looping) const playing = computed(() => store.state.player.playing) const loading = computed(() => store.state.player.isLoadingAudio) const errored = computed(() => store.state.player.errored) const focused = computed(() => store.state.ui.queueFocused === 'player') - const volume = computed(() => store.state.player.volume) + // Volume + const volume = computed({ + get: () => store.state.player.volume, + set: (value) => store.commit('player/volume', value) + }) + watchEffect(() => Howler.volume(toLinearVolumeScale(volume.value))) + + // Time and duration const duration = computed(() => store.state.player.duration) const currentTime = computed(() => store.state.player.currentTime) const durationFormatted = computed(() => store.getters['player/durationFormatted']) const currentTimeFormatted = computed(() => store.getters['player/currentTimeFormatted']) + // Progress const progress = computed(() => store.getters['player/progress']) const bufferProgress = computed(() => store.state.player.bufferProgress) + // Controls const pause = () => store.dispatch('player/pausePlayback') const resume = () => store.dispatch('player/resumePlayback') + const { next } = useQueue() + const seek = (step: number) => { + // seek right + if (step > 0) { + if (currentTime.value + step < duration.value) { + store.dispatch('player/updateProgress', (currentTime.value + step)) + } else { + next() + } + + return + } + + // seek left + const position = Math.max(currentTime.value + step, 0) + store.dispatch('player/updateProgress', position) + } + + const togglePlayback = () => { + if (playing.value) return pause() + return resume() + } + return { looping, playing, @@ -42,6 +76,8 @@ export default () => { bufferProgress, pause, - resume + resume, + seek, + togglePlayback } } \ No newline at end of file diff --git a/front/src/composables/useQueue.ts b/front/src/composables/useQueue.ts index ab88e957f..2a4fcea2a 100644 --- a/front/src/composables/useQueue.ts +++ b/front/src/composables/useQueue.ts @@ -1,19 +1,21 @@ -import { useTimeoutFn, useThrottleFn } from "@vueuse/core" -import { useTimeAgo, useNow } from '@vueuse/core' -import { useGettext } from "vue3-gettext" -import { useStore } from "~/store" +import { useTimeoutFn, useThrottleFn, useTimeAgo, useNow, whenever } from '@vueuse/core' +import { Howler } from 'howler' +import { gettext } from '~/init/locale' import { ref, computed } from "vue" import { Track } from "~/types" import { sum } from 'lodash-es' +import store from "~/store" + +const { $pgettext } = gettext export default () => { - const store = useStore() - const { $pgettext } = useGettext() - const currentTrack = computed(() => store.getters['queue/currentTrack']) const currentIndex = computed(() => store.state.queue.currentIndex) const hasNext = computed(() => store.getters['queue/hasNext']) + const hasPrevious = computed(() => store.getters['queue/hasPrevious']) + const isEmpty = computed(() => store.getters['queue/isEmpty']) + whenever(isEmpty, () => Howler.unload()) const removeTrack = (index: number) => store.dispatch('queue/cleanTrack', index) const clear = () => store.dispatch('queue/clean') @@ -87,8 +89,11 @@ export default () => { return { currentTrack, + currentIndex, hasNext, - isEmpty, + hasPrevious, + isEmpty, + isShuffling, removeTrack, clear, diff --git a/front/src/init/howler.ts b/front/src/init/howler.ts new file mode 100644 index 000000000..082e62ef0 --- /dev/null +++ b/front/src/init/howler.ts @@ -0,0 +1,17 @@ +import { InitModule } from '~/types' +import { Howl } from 'howler' + +export const install: InitModule = ({ app }) => { + // TODO (wvffle): Check if it is needed + + // this is needed to unlock audio playing under some browsers, + // cf https://github.com/goldfire/howler.js#mobilechrome-playback + // but we never actually load those audio files + const dummyAudio = new Howl({ + preload: false, + autoplay: false, + src: ['noop.webm', 'noop.mp3'] + }) + + return dummyAudio +} diff --git a/front/src/init/mediaSession.ts b/front/src/init/mediaSession.ts new file mode 100644 index 000000000..c2fc191b3 --- /dev/null +++ b/front/src/init/mediaSession.ts @@ -0,0 +1,45 @@ +import { InitModule } from '~/types' +import { whenever } from '@vueuse/core' +import useQueue from '~/composables/useQueue' +import usePlayer from '~/composables/usePlayer' + +export const install: InitModule = ({ app }) => { + const { currentTrack, next, previous } = useQueue() + const { resume, pause, seek } = usePlayer() + + // Add controls for notification drawer + if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', resume) + navigator.mediaSession.setActionHandler('pause', pause) + navigator.mediaSession.setActionHandler('seekforward', () => seek(5)) + navigator.mediaSession.setActionHandler('seekbackward', () => seek(-5)) + navigator.mediaSession.setActionHandler('nexttrack', next) + navigator.mediaSession.setActionHandler('previoustrack', previous) + + // TODO (wvffle): set metadata to null when we don't have currentTrack? + // If the session is playing as a PWA, populate the notification + // with details from the track + whenever(currentTrack, () => { + const { title, artist, album } = currentTrack.value + + const metadata: MediaMetadataInit = { + title, + artist: artist.name + } + + if (album?.cover) { + metadata.album = album.title + metadata.artwork = [ + { src: album.cover.urls.original, sizes: '96x96', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '128x128', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '192x192', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '256x256', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '384x384', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '512x512', type: 'image/png' } + ] + } + + navigator.mediaSession.metadata = new window.MediaMetadata(metadata) + }, { immediate: true }) + } +} diff --git a/front/src/init/webSocket.ts b/front/src/init/webSocket.ts index e32a55a7f..3177596b6 100644 --- a/front/src/init/webSocket.ts +++ b/front/src/init/webSocket.ts @@ -1,6 +1,14 @@ -import { InitModule } from '~/types' +import { + InitModule, + ListenWSEvent, + PendingReviewEditsWSEvent, + PendingReviewReportsWSEvent, + PendingReviewRequestsWSEvent, +} from '~/types' import { watchEffect, watch } from 'vue' import { useWebSocket, whenever } from '@vueuse/core' +import useWebSocketHandler from '~/composables/useWebSocketHandler' +import { CLIENT_RADIOS } from '~/utils/clientRadios' export const install: InitModule = ({ store }) => { watch(() => store.state.instance.instanceUrl, () => { @@ -25,4 +33,47 @@ export const install: InitModule = ({ store }) => { console.log('Websocket status:', status.value) }) }, { immediate: true }) + + // WebSocket handlers + useWebSocketHandler('inbox.item_added', () => { + store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) + }) + + useWebSocketHandler('mutation.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewEdits', + value: (event as PendingReviewEditsWSEvent).pending_review_count + }) + }) + + useWebSocketHandler('mutation.updated', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewEdits', + value: (event as PendingReviewEditsWSEvent).pending_review_count + }) + }) + + useWebSocketHandler('report.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewReports', + value: (event as PendingReviewReportsWSEvent).unresolved_count + }) + }) + + useWebSocketHandler('user_request.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewRequests', + value: (event as PendingReviewRequestsWSEvent).pending_count + }) + }) + + useWebSocketHandler('Listen', (event) => { + if (store.state.radios.current && store.state.radios.running) { + const { current } = store.state.radios + + if (current.clientOnly) { + CLIENT_RADIOS[current.type].handleListen(current, event as ListenWSEvent, store) + } + } + }) } diff --git a/front/src/shims-vuex.d.ts b/front/src/shims-vuex.d.ts index aa9a199bd..9658d05c7 100644 --- a/front/src/shims-vuex.d.ts +++ b/front/src/shims-vuex.d.ts @@ -1,7 +1,8 @@ import { Store } from 'vuex' +import { RootState } from '~/store' declare module '@vue/runtime-core' { interface ComponentCustomProperties { - $store: Store + $store: Store } } diff --git a/front/src/store/ui.ts b/front/src/store/ui.ts index bd98ea38c..86d28c186 100644 --- a/front/src/store/ui.ts +++ b/front/src/store/ui.ts @@ -47,7 +47,7 @@ type NotificationsKey = 'inbox' | 'pendingReviewEdits' | 'pendingReviewReports' export interface State { currentLanguage: 'en_US' | keyof typeof availableLanguages selectedLanguage: boolean - queueFocused: null + queueFocused: null | 'queue' | 'player' momentLocale: 'en' lastDate: Date maxMessages: number diff --git a/front/src/types.ts b/front/src/types.ts index da8bf23a1..d80986942 100644 --- a/front/src/types.ts +++ b/front/src/types.ts @@ -2,6 +2,7 @@ import type { App } from 'vue' import type { Store } from 'vuex' import { Router } from 'vue-router' import { AxiosError } from 'axios' +import { RootState } from '~/store' declare global { interface Window { @@ -14,7 +15,7 @@ declare global { export interface InitModuleContext { app: App router: Router - store: Store + store: Store } export type InitModule = (ctx: InitModuleContext) => void @@ -71,8 +72,12 @@ export interface Track { album?: Album artist?: Artist + + // TODO (wvffle): Make sure it really has listen_url + listen_url: string } + export interface Channel { id: string artist?: Artist @@ -171,6 +176,9 @@ export interface Upload { source?: string uuid: string duration?: number + mimetype: string + extension: string + listen_url: string } // FileSystem Logs diff --git a/front/src/utils/index.ts b/front/src/utils/index.ts index e1c354cf4..30374c7fc 100644 --- a/front/src/utils/index.ts +++ b/front/src/utils/index.ts @@ -2,6 +2,7 @@ import { startCase } from 'lodash-es' import { Store } from 'vuex' import { Router } from 'vue-router' import { APIErrorResponse } from '~/types' +import { RootState } from '~/store' export function setUpdate (obj: object, statuses: Record, value: unknown) { for (const key of Object.keys(obj)) { @@ -46,7 +47,7 @@ export function getCookie (name: string) { } // TODO (wvffle): Use navigation guards -export async function checkRedirectToLogin (store: Store, router: Router) { +export async function checkRedirectToLogin (store: Store, router: Router) { if (!store.state.auth.authenticated) { return router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } }) } diff --git a/front/yarn.lock b/front/yarn.lock index 27c749a62..3d554c94c 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1390,6 +1390,11 @@ dependencies: "@types/node" "*" +"@types/howler@^2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae" + integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"