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"