Rewrite player component to script setup

This commit is contained in:
wvffle 2022-07-03 18:12:09 +00:00 committed by Georg Krause
parent 8c11b6d0ea
commit cec34d49fa
15 changed files with 678 additions and 680 deletions

View File

@ -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",

View File

@ -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(() => {

File diff suppressed because it is too large Load Diff

View File

@ -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<number>) => {
const soundCache = reactive(new Map<string, CachedSound>())
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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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,

17
front/src/init/howler.ts Normal file
View File

@ -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
}

View File

@ -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 })
}
}

View File

@ -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)
}
}
})
}

View File

@ -1,7 +1,8 @@
import { Store } from 'vuex'
import { RootState } from '~/store'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$store: Store<any>
$store: Store<RootState>
}
}

View File

@ -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

View File

@ -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<any>
store: Store<RootState>
}
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

View File

@ -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<string, unknown>, 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<any>, router: Router) {
export async function checkRedirectToLogin (store: Store<RootState>, router: Router) {
if (!store.state.auth.authenticated) {
return router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } })
}

View File

@ -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"