feat: add jukebox player engine for server-side playback (#2109)

* feat: add jukebox player engine for server-side playback

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
mannansainidev
2026-07-10 09:59:22 +05:30
committed by GitHub
parent dd136f91e6
commit 0c89fea1ea
15 changed files with 743 additions and 31 deletions
+12
View File
@@ -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);
@@ -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;
@@ -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',
@@ -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: {
@@ -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<JukeboxControlQuery, 'action'>,
) => Promise<JukeboxControlResponse>;
interface JukeboxPlayerEngineProps {
currentTrackId: null | string;
enabled: boolean;
isMuted: boolean;
nextTrackId: null | string;
onEnded: () => PlayerData;
onServerStateSynced?: (state: JukeboxServerState) => void;
onTick: (positionSeconds: number) => void;
playerRef: RefObject<JukeboxPlayerEngineHandle | null>;
playerStatus: PlayerStatus;
serverId: string;
volume: number;
}
type JukeboxQueueRefs = {
lastCurrentTrackIdRef: RefObject<null | string>;
lastNextTrackIdRef: RefObject<null | string>;
lastServerIndexRef: RefObject<number>;
};
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<JukeboxControlResponse>['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<NodeJS.Timeout | null>(null);
const lastPositionRef = useRef<number>(-1);
const lastCurrentTrackIdRef = useRef<null | string>(null);
const lastNextTrackIdRef = useRef<null | string>(null);
const lastServerIndexRef = useRef<number>(-1);
const isChangingTrackRef = useRef<boolean>(false);
const serverPlayingRef = useRef<boolean>(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<JukeboxPlayerEngineHandle, JukeboxPlayerEngineHandle>(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 <div id="jukebox-player-engine" style={{ display: 'none' }} />;
};
JukeboxPlayerEngine.displayName = 'JukeboxPlayerEngine';
@@ -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<JukeboxControlResponse, AxiosError, JukeboxControlArgs>({
mutationFn: (args) => {
return api.controller.jukeboxControl!({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
});
};
@@ -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<JukeboxPlayerEngineHandle>(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 (
<JukeboxPlayerEngine
currentTrackId={currentTrackId}
enabled={playerHydrated && Boolean(serverId)}
isMuted={isMuted}
nextTrackId={nextTrackId}
onEnded={mediaAutoNext}
onServerStateSynced={handleServerStateSynced}
onTick={(positionSeconds) => {
setTimestamp(Math.floor(positionSeconds));
}}
playerRef={playerRef}
playerStatus={status}
serverId={serverId}
volume={volume}
/>
);
}
@@ -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 && <WebPlayer />}
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
{playbackType === PlayerType.JUKEBOX && <JukeboxPlayer />}
</>
);
};
@@ -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}
@@ -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: (
<Select
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlayerType.LOCAL,
},
{ label: 'Web', value: PlayerType.WEB },
]}
data={selectData}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
@@ -115,10 +127,8 @@ export const AudioSettings = memo(() => {
}}
/>
),
description: t('setting.audioPlayer', {
context: 'description',
}),
isHidden: !isElectron(),
description: t('setting.audioPlayer', { context: 'description' }),
isHidden: !isElectron() && !isJukeboxSupported,
note: status === PlayerStatus.PLAYING ? t('common.playerMustBePaused') : undefined,
title: t('setting.audioPlayer'),
},
@@ -139,9 +149,7 @@ export const AudioSettings = memo(() => {
}
/>
),
description: t('setting.audioDevice', {
context: 'description',
}),
description: t('setting.audioDevice', { context: 'description' }),
isHidden: !isElectron(),
title: t('setting.audioDevice'),
},
@@ -156,9 +164,7 @@ export const AudioSettings = memo(() => {
}}
/>
),
description: t('setting.webAudio', {
context: 'description',
}),
description: t('setting.webAudio', { context: 'description' }),
isHidden: settings.type !== PlayerType.WEB,
note: t('common.restartRequired'),
title: t('setting.webAudio'),
@@ -174,9 +180,7 @@ export const AudioSettings = memo(() => {
}}
/>
),
description: t('setting.preservePitch', {
context: 'description',
}),
description: t('setting.preservePitch', { context: 'description' }),
isHidden: settings.type !== PlayerType.WEB,
title: t('setting.preservePitch'),
},
@@ -186,16 +190,12 @@ export const AudioSettings = memo(() => {
defaultChecked={settings.audioFadeOnStatusChange}
onChange={(e) => {
setSettings({
playback: {
audioFadeOnStatusChange: e.currentTarget.checked,
},
playback: { audioFadeOnStatusChange: e.currentTarget.checked },
});
}}
/>
),
description: t('setting.audioFadeOnStatusChange', {
context: 'description',
}),
description: t('setting.audioFadeOnStatusChange', { context: 'description' }),
title: t('setting.audioFadeOnStatusChange'),
},
];
+51
View File
@@ -805,6 +805,53 @@ const reportPlaybackParameters = z.object({
const reportPlayback = z.null();
const jukeboxControlParameters = z.object({
action: z.enum([
'start',
'stop',
'skip',
'set',
'get',
'setGain',
'add',
'clear',
'remove',
'shuffle',
'status',
]),
gain: z.number().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
index: z.number().optional(),
offset: z.number().optional(),
});
const jukeboxPlaylistEntry = z.object({
album: z.string().optional(),
artist: z.string().optional(),
coverArt: z.string().optional(),
duration: z.number().optional(),
id: z.string(),
isDir: z.boolean(),
parent: z.string().optional(),
title: z.string(),
});
const jukeboxStatus = z.object({
currentIndex: z.number().optional(),
gain: z.number(),
playing: z.boolean(),
position: z.number().optional(),
});
const jukeboxPlaylist = jukeboxStatus.extend({
entry: z.array(jukeboxPlaylistEntry).optional(),
});
const jukeboxControl = z.object({
jukeboxPlaylist: jukeboxPlaylist.optional(),
jukeboxStatus: jukeboxStatus.optional(),
});
export const ssType = {
_body: {
getTranscodeDecision: transcodeDecisionRequestBody,
@@ -834,6 +881,7 @@ export const ssType = {
getStarred: getStarredParameters,
getTranscodeDecision: transcodeDecisionParameters,
getTranscodeStream: getTranscodeStreamParameters,
jukeboxControl: jukeboxControlParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
reportPlayback: reportPlaybackParameters,
@@ -882,6 +930,9 @@ export const ssType = {
getStarred,
getTranscodeDecision,
internetRadioStation,
jukeboxControl,
jukeboxPlaylist,
jukeboxStatus,
musicFolderList,
ping,
playlist,
+52
View File
@@ -1540,6 +1540,7 @@ export type ControllerEndpoint = {
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
@@ -1705,6 +1706,9 @@ export type InternalControllerEndpoint = {
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
jukeboxControl?: (
args: ReplaceApiClientProps<JukeboxControlArgs>,
) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
removeFromPlaylist: (
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
@@ -1737,6 +1741,54 @@ export type InternalControllerEndpoint = {
) => Promise<UploadPlaylistImageResponse>;
};
export type JukeboxControlAction =
| 'add'
| 'clear'
| 'get'
| 'remove'
| 'set'
| 'setGain'
| 'shuffle'
| 'skip'
| 'start'
| 'status'
| 'stop';
export type JukeboxControlArgs = BaseEndpointArgs & { query: JukeboxControlQuery };
export type JukeboxControlQuery = {
action: JukeboxControlAction;
gain?: number;
id?: string | string[];
index?: number;
offset?: number;
};
export type JukeboxControlResponse = null | {
jukeboxPlaylist?: {
currentIndex?: number;
entry?: Array<{
album?: string;
artist?: string;
coverArt?: string;
duration?: number;
id: string;
isDir: boolean;
parent?: string;
title: string;
}>;
gain: number;
playing: boolean;
position?: number;
};
jukeboxStatus?: {
currentIndex?: number;
gain: number;
playing: boolean;
position?: number;
};
};
export type LyricGetQuery = {
remoteSongId: string;
remoteSource: LyricSource;
+1
View File
@@ -5,6 +5,7 @@ export enum ServerFeature {
ARTIST_IMAGE_UPLOAD = 'artistImageUpload',
BFR = 'bfr',
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
JUKEBOX = 'jukebox',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
+1
View File
@@ -158,6 +158,7 @@ export enum PlayerStyle {
}
export enum PlayerType {
JUKEBOX = 'jukebox',
LOCAL = 'local',
WEB = 'web',
}