Rewrite player component to script setup
This commit is contained in:
parent
8c11b6d0ea
commit
cec34d49fa
|
@ -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",
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue