diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index c8b2bfdbf..777e51cdc 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -762,6 +762,18 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + jukeboxControl(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error(`${i18n.t('error.apiRouteError')}: jukeboxControl`); + } + + const fn = apiController('jukeboxControl', server.type); + return fn + ? fn(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })) + : Promise.resolve(null); + }, movePlaylistItem(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index d1080d0a8..4ab9fe9a1 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -745,6 +745,7 @@ export const NavidromeController: InternalControllerEndpoint = { ...navidromeFeatures, publicPlaylist: [1], [ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1], + [ServerFeature.JUKEBOX]: [1], [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], }; @@ -866,12 +867,12 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: albums.totalRecordCount, }; }, - getSongListCount: async ({ apiClientProps, query }) => NavidromeController.getSongList({ apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getStreamUrl: SubsonicController.getStreamUrl, getStructuredLyrics: SubsonicController.getStructuredLyrics, getTagList: async (args) => { @@ -1008,6 +1009,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, + jukeboxControl: SubsonicController.jukeboxControl, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 651ef173c..f34bce826 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -274,6 +274,14 @@ export const contract = c.router({ 200: ssType._response.user, }, }, + jukeboxControl: { + method: 'GET', + path: 'jukeboxControl.view', + query: ssType._parameters.jukeboxControl, + responses: { + 200: ssType._response.jukeboxControl, + }, + }, ping: { method: 'GET', path: 'ping.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 0af0f095e..87095e0ee 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1395,6 +1395,22 @@ export const SubsonicController: InternalControllerEndpoint = { if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) { features.reportPlayback = [1]; } + try { + const jukeboxStatus = await ssApiClient(apiClientProps).jukeboxControl({ + query: { action: 'status' }, + }); + + if (jukeboxStatus.status === 200 && !(jukeboxStatus.body as any)?.error) { + features[ServerFeature.JUKEBOX] = [1]; + } else { + console.log( + 'Jukebox endpoint returned an error payload:', + (jukeboxStatus.body as any)?.error, + ); + } + } catch (error) { + console.log('Jukebox is not supported by this server:', error); + } return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; }, @@ -2052,6 +2068,25 @@ export const SubsonicController: InternalControllerEndpoint = { name: res.body.user.username, }; }, + jukeboxControl: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).jukeboxControl({ + query: { + action: query.action, + gain: query.gain, + id: query.id, + index: query.index, + offset: query.offset, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to control jukebox'); + } + + return res.body; + }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { diff --git a/src/renderer/features/player/audio-player/engine/jukebox-player-engine.tsx b/src/renderer/features/player/audio-player/engine/jukebox-player-engine.tsx new file mode 100644 index 000000000..eda2405c1 --- /dev/null +++ b/src/renderer/features/player/audio-player/engine/jukebox-player-engine.tsx @@ -0,0 +1,422 @@ +import type { RefObject } from 'react'; + +import { useEffect, useImperativeHandle, useRef, useState } from 'react'; + +import { useJukeboxControl } from '/@/renderer/features/player/audio-player/hooks/use-jukebox-control'; +import { AudioPlayer } from '/@/renderer/features/player/audio-player/types'; +import { + JukeboxControlAction, + JukeboxControlQuery, + JukeboxControlResponse, + PlayerData, +} from '/@/shared/types/domain-types'; +import { PlayerStatus } from '/@/shared/types/types'; + +export interface JukeboxPlayerEngineHandle extends AudioPlayer {} + +export type JukeboxServerState = { + gain: number; + nextTrackId: null | string; + playing: boolean; + position: number; + trackId: null | string; +}; + +type JukeboxCallApi = ( + action: JukeboxControlAction, + queryParams?: Omit, +) => Promise; + +interface JukeboxPlayerEngineProps { + currentTrackId: null | string; + enabled: boolean; + isMuted: boolean; + nextTrackId: null | string; + onEnded: () => PlayerData; + onServerStateSynced?: (state: JukeboxServerState) => void; + onTick: (positionSeconds: number) => void; + playerRef: RefObject; + playerStatus: PlayerStatus; + serverId: string; + volume: number; +} + +type JukeboxQueueRefs = { + lastCurrentTrackIdRef: RefObject; + lastNextTrackIdRef: RefObject; + lastServerIndexRef: RefObject; +}; + +const TRACK_SETUP_DELAY_MS = 300; + +const buildQueueIds = (currentId: string, nextId?: null | string): string | string[] => { + if (nextId) { + return [currentId, nextId]; + } + + return currentId; +}; + +const getPlaylistTrackIds = ( + playlist: NonNullable['jukeboxPlaylist'], +): { currentTrackId: null | string; nextTrackId: null | string } => { + if (!playlist?.entry?.length) { + return { currentTrackId: null, nextTrackId: null }; + } + + const currentIndex = playlist.currentIndex ?? 0; + const currentTrackId = playlist.entry[currentIndex]?.id ?? null; + const nextTrackId = playlist.entry[currentIndex + 1]?.id ?? null; + + return { currentTrackId, nextTrackId }; +}; + +const replaceJukeboxQueue = async ( + callApi: JukeboxCallApi, + refs: JukeboxQueueRefs, + currentId: null | string, + nextId: null | string, + shouldStart: boolean, +) => { + if (!currentId) { + await callApi('clear'); + refs.lastCurrentTrackIdRef.current = null; + refs.lastNextTrackIdRef.current = null; + refs.lastServerIndexRef.current = -1; + return; + } + + await callApi('set', { id: buildQueueIds(currentId, nextId) }); + await callApi('skip', { index: 0, offset: 0 }); + + if (shouldStart) { + await callApi('start'); + } + + refs.lastCurrentTrackIdRef.current = currentId; + refs.lastNextTrackIdRef.current = nextId; + refs.lastServerIndexRef.current = 0; +}; + +const setJukeboxQueueNext = async ( + callApi: JukeboxCallApi, + refs: JukeboxQueueRefs, + nextId: null | string, +) => { + const res = await callApi('get'); + const entryCount = res?.jukeboxPlaylist?.entry?.length ?? 0; + + if (entryCount > 1) { + await callApi('remove', { index: 1 }); + } + + if (nextId) { + await callApi('add', { id: nextId }); + } + + refs.lastNextTrackIdRef.current = nextId; +}; + +const handleJukeboxAutoNext = async ( + callApi: JukeboxCallApi, + refs: JukeboxQueueRefs, + playerData: PlayerData, +) => { + await callApi('remove', { index: 0 }); + + const newNextId = playerData.nextSong?.id ?? null; + if (newNextId) { + await callApi('add', { id: newNextId }); + } + + refs.lastCurrentTrackIdRef.current = playerData.currentSong?.id ?? null; + refs.lastNextTrackIdRef.current = newNextId; + refs.lastServerIndexRef.current = 0; +}; + +export const JukeboxPlayerEngine = (props: JukeboxPlayerEngineProps) => { + const { + currentTrackId, + enabled, + isMuted, + nextTrackId, + onEnded, + onServerStateSynced, + onTick, + playerRef, + playerStatus, + serverId, + volume, + } = props; + + const pollRef = useRef(null); + const lastPositionRef = useRef(-1); + const lastCurrentTrackIdRef = useRef(null); + const lastNextTrackIdRef = useRef(null); + const lastServerIndexRef = useRef(-1); + const isChangingTrackRef = useRef(false); + const serverPlayingRef = useRef(false); + const queueRefs: JukeboxQueueRefs = { + lastCurrentTrackIdRef, + lastNextTrackIdRef, + lastServerIndexRef, + }; + const [gainValue, setGainValue] = useState(volume / 100); + const [isSyncedFromServer, setIsSyncedFromServer] = useState(false); + + const jukeboxControlMutation = useJukeboxControl(); + + const callApi: JukeboxCallApi = async (action, queryParams = {}) => { + if (!serverId) return null; + try { + return await jukeboxControlMutation.mutateAsync({ + apiClientProps: { serverId }, + query: { + action, + ...queryParams, + }, + }); + } catch { + return null; + } + }; + + // 0. On startup, read existing server jukebox state before pushing local state + useEffect(() => { + if (!enabled) { + return; + } + + let cancelled = false; + + const syncFromServer = async () => { + setIsSyncedFromServer(false); + + const res = await callApi('get'); + if (cancelled) { + return; + } + + const playlist = res?.jukeboxPlaylist; + if (!playlist) { + serverPlayingRef.current = false; + setIsSyncedFromServer(true); + return; + } + + const { currentTrackId: serverTrackId, nextTrackId: serverNextTrackId } = + getPlaylistTrackIds(playlist); + const { gain, playing, position } = playlist; + + lastCurrentTrackIdRef.current = serverTrackId; + lastNextTrackIdRef.current = serverNextTrackId; + lastServerIndexRef.current = playlist.currentIndex ?? 0; + + if (position !== undefined && position !== null) { + lastPositionRef.current = position; + } + + serverPlayingRef.current = playing; + + if (gain !== undefined) { + setGainValue(gain); + } + + onServerStateSynced?.({ + gain: gain ?? gainValue, + nextTrackId: serverNextTrackId, + playing, + position: position ?? 0, + trackId: serverTrackId, + }); + + if (!cancelled) { + setIsSyncedFromServer(true); + } + }; + + syncFromServer(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, serverId]); + + // 1. Keep server queue aligned with local current + next songs + useEffect(() => { + if (!isSyncedFromServer || isChangingTrackRef.current) { + return; + } + + const syncQueue = async () => { + if (!currentTrackId) { + if (!serverPlayingRef.current) { + await callApi('clear'); + lastCurrentTrackIdRef.current = null; + lastNextTrackIdRef.current = null; + lastServerIndexRef.current = -1; + lastPositionRef.current = -1; + } + return; + } + + const currentChanged = currentTrackId !== lastCurrentTrackIdRef.current; + const nextChanged = nextTrackId !== lastNextTrackIdRef.current; + + if (!currentChanged && !nextChanged) { + return; + } + + isChangingTrackRef.current = true; + + try { + if (currentChanged) { + await replaceJukeboxQueue( + callApi, + queueRefs, + currentTrackId, + nextTrackId, + playerStatus === PlayerStatus.PLAYING, + ); + serverPlayingRef.current = playerStatus === PlayerStatus.PLAYING; + lastPositionRef.current = -1; + } else if (nextChanged) { + await setJukeboxQueueNext(callApi, queueRefs, nextTrackId); + } + } finally { + setTimeout(() => { + isChangingTrackRef.current = false; + }, TRACK_SETUP_DELAY_MS); + } + }; + + syncQueue(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTrackId, isSyncedFromServer, nextTrackId, playerStatus]); + + // 2. Play/Pause Matcher + useEffect(() => { + if (!isSyncedFromServer || isChangingTrackRef.current) { + return; + } + + const shouldPlay = playerStatus === PlayerStatus.PLAYING; + if (serverPlayingRef.current === shouldPlay) { + return; + } + + if (shouldPlay) { + callApi('start'); + } else { + callApi('stop'); + } + + serverPlayingRef.current = shouldPlay; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerStatus, isSyncedFromServer]); + + // 3. Audio Level Matcher + useEffect(() => { + if (!isSyncedFromServer) { + return; + } + + const gain = isMuted ? 0 : gainValue; + callApi('setGain', { gain }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gainValue, isMuted, isSyncedFromServer]); + + // 4. Position Tick Updates + server-side auto-advance detection + useEffect(() => { + if (pollRef.current) clearInterval(pollRef.current); + if (!isSyncedFromServer || playerStatus !== PlayerStatus.PLAYING) return; + + pollRef.current = setInterval(async () => { + if (isChangingTrackRef.current) return; + + const res = await callApi('get'); + if (!res?.jukeboxPlaylist) return; + + const playlist = res.jukeboxPlaylist; + const { playing, position } = playlist; + const currentIndex = playlist.currentIndex ?? 0; + const { currentTrackId: serverTrackId } = getPlaylistTrackIds(playlist); + + serverPlayingRef.current = playing; + + const serverAutoAdvanced = + playing && + lastServerIndexRef.current === 0 && + currentIndex === 1 && + serverTrackId === lastNextTrackIdRef.current; + + if (serverAutoAdvanced) { + isChangingTrackRef.current = true; + try { + const playerData = onEnded(); + await handleJukeboxAutoNext(callApi, queueRefs, playerData); + lastPositionRef.current = position ?? 0; + if (position !== undefined && position !== null) { + onTick(position); + } + } finally { + isChangingTrackRef.current = false; + } + + lastServerIndexRef.current = currentIndex; + return; + } + + lastServerIndexRef.current = currentIndex; + + if (!playing && lastPositionRef.current > 0) { + lastPositionRef.current = -1; + onEnded(); + return; + } + + if (position !== undefined && position !== null) { + lastPositionRef.current = position; + onTick(position); + } + }, 1000); + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSyncedFromServer, playerStatus, onEnded, onTick]); + + useImperativeHandle(playerRef, () => ({ + decreaseVolume(by: number) { + const next = Math.max(0, gainValue - by / 100); + setGainValue(next); + }, + increaseVolume(by: number) { + const next = Math.min(1, gainValue + by / 100); + setGainValue(next); + }, + pause() { + serverPlayingRef.current = false; + callApi('stop'); + }, + play() { + serverPlayingRef.current = true; + callApi('start'); + }, + seekTo(seconds: number) { + if (isChangingTrackRef.current) return; + callApi('skip', { index: 0, offset: Math.floor(seconds) }); + }, + setVolume(vol: number) { + const gain = vol / 100; + setGainValue(gain); + callApi('setGain', { gain }); + }, + })); + + return
; +}; + +JukeboxPlayerEngine.displayName = 'JukeboxPlayerEngine'; diff --git a/src/renderer/features/player/audio-player/hooks/use-jukebox-control.ts b/src/renderer/features/player/audio-player/hooks/use-jukebox-control.ts new file mode 100644 index 000000000..67c6ab48b --- /dev/null +++ b/src/renderer/features/player/audio-player/hooks/use-jukebox-control.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { JukeboxControlArgs, JukeboxControlResponse } from '/@/shared/types/domain-types'; + +export const useJukeboxControl = () => { + return useMutation({ + mutationFn: (args) => { + return api.controller.jukeboxControl!({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + }); +}; diff --git a/src/renderer/features/player/audio-player/jukebox-player.tsx b/src/renderer/features/player/audio-player/jukebox-player.tsx new file mode 100644 index 000000000..5e45fa900 --- /dev/null +++ b/src/renderer/features/player/audio-player/jukebox-player.tsx @@ -0,0 +1,110 @@ +import { useCallback, useRef } from 'react'; + +import { + JukeboxPlayerEngine, + JukeboxPlayerEngineHandle, + JukeboxServerState, +} from '/@/renderer/features/player/audio-player/engine/jukebox-player-engine'; +import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { + useCurrentServerId, + usePlayerActions, + usePlayerData, + usePlayerHydrated, + usePlayerMuted, + usePlayerStore, + usePlayerVolume, +} from '/@/renderer/store'; +import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; + +export function JukeboxPlayer() { + const playerRef = useRef(null); + const { currentSong, nextSong, status } = usePlayerData(); + const { mediaAutoNext, mediaPause, mediaPlay, mediaPlayByIndex, setTimestamp, setVolume } = + usePlayerActions(); + const isMuted = usePlayerMuted(); + const volume = usePlayerVolume(); + const player = usePlayer(); + const playerHydrated = usePlayerHydrated(); + + const serverId = useCurrentServerId(); + + const currentTrackId = currentSong?.id ?? null; + const nextTrackId = nextSong?.id ?? null; + + const handleServerStateSynced = useCallback( + (state: JukeboxServerState) => { + const { gain, playing, position, trackId } = state; + + if (trackId) { + const queue = usePlayerStore.getState().getQueue(); + const queueIndex = queue.items.findIndex((item) => item.id === trackId); + + if (queueIndex !== -1) { + const currentId = usePlayerStore.getState().getCurrentSong()?.id; + if (trackId !== currentId) { + mediaPlayByIndex(queueIndex); + } + } + } + + if (position > 0) { + setTimestamp(Math.floor(position)); + } + + if (playing) { + mediaPlay(); + } else { + mediaPause(); + } + + const volumeFromGain = Math.round(gain * 100); + if (volumeFromGain !== volume) { + setVolume(volumeFromGain); + } + }, + [mediaPause, mediaPlay, mediaPlayByIndex, setTimestamp, setVolume, volume], + ); + + const debouncedSeekToTimestamp = useDebouncedCallback((timestamp: number) => { + playerRef.current?.seekTo(timestamp); + }, 300); + + const debouncedSetVolume = useDebouncedCallback((nextVolume: number) => { + playerRef.current?.setVolume(nextVolume); + }, 300); + + usePlayerEvents( + { + onPlayerSeekToTimestamp: (properties) => { + debouncedSeekToTimestamp(properties.timestamp); + }, + onPlayerVolume: (properties) => { + debouncedSetVolume(properties.volume); + }, + onQueueCleared: () => { + player.mediaStop(); + }, + }, + [debouncedSeekToTimestamp, debouncedSetVolume, player], + ); + + return ( + { + setTimestamp(Math.floor(positionSeconds)); + }} + playerRef={playerRef} + playerStatus={status} + serverId={serverId} + volume={volume} + /> + ); +} diff --git a/src/renderer/features/player/audio-players.tsx b/src/renderer/features/player/audio-players.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index ba868c50f..8ab9af5d2 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -5,6 +5,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener'; +import { JukeboxPlayer } from '/@/renderer/features/player/audio-player/jukebox-player'; import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player'; import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player'; import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button'; @@ -42,7 +43,6 @@ import { logFn } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem } from '/@/shared/types/domain-types'; import { PlayerType } from '/@/shared/types/types'; - const CODEC_PROBES = [ { codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' }, @@ -329,6 +329,7 @@ const AudioPlayersContent = ({ <> {playbackType === PlayerType.WEB && } {playbackType === PlayerType.LOCAL && } + {playbackType === PlayerType.JUKEBOX && } ); }; diff --git a/src/renderer/features/player/components/player-config.tsx b/src/renderer/features/player/components/player-config.tsx index f78bc180a..557a17e84 100644 --- a/src/renderer/features/player/components/player-config.tsx +++ b/src/renderer/features/player/components/player-config.tsx @@ -218,6 +218,7 @@ const AudioPlayerTypeConfig = () => { value: PlayerType.LOCAL, }, { label: 'Web', value: PlayerType.WEB }, + { label: 'Jukebox', value: PlayerType.JUKEBOX }, ]} defaultValue={playbackSettings.type} disabled={status === PlayerStatus.PLAYING} diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index fe40c2b9e..a0099558a 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -7,13 +7,14 @@ import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; -import { usePlaybackType, usePlayerStatus } from '/@/renderer/store'; +import { useCurrentServer, usePlaybackType, usePlayerStatus } from '/@/renderer/store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { hasFeature } from '/@/shared/api/utils'; import { Select } from '/@/shared/components/select/select'; import { Switch } from '/@/shared/components/switch/switch'; import { toast } from '/@/shared/components/toast/toast'; +import { ServerFeature } from '/@/shared/types/features-types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types'; - const ipc = isElectron() ? window.api.ipc : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; @@ -91,22 +92,33 @@ export const AudioSettings = memo(() => { const status = usePlayerStatus(); const playbackType = usePlaybackType(); + // Cleaned up server feature logic via requested hooks/utilities + const currentServer = useCurrentServer(); + const isJukeboxSupported = hasFeature(currentServer, ServerFeature.JUKEBOX); + const audioDevices = useAudioDevices(playbackType); const audioDeviceId = playbackType === PlayerType.LOCAL ? settings.mpvAudioDeviceId : settings.audioDeviceId; + // Dynamically build the options for the dropdown + const selectData = [ + { + disabled: !isElectron(), + label: 'MPV', + value: PlayerType.LOCAL, + }, + { label: 'Web', value: PlayerType.WEB }, + ]; + + if (isJukeboxSupported) { + selectData.push({ label: 'Jukebox', value: PlayerType.JUKEBOX }); + } + const audioOptions: SettingOption[] = [ { control: (