mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-18 08:26:29 +02:00
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:
@@ -762,6 +762,18 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(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) {
|
movePlaylistItem(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -745,6 +745,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
...navidromeFeatures,
|
...navidromeFeatures,
|
||||||
publicPlaylist: [1],
|
publicPlaylist: [1],
|
||||||
[ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1],
|
[ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1],
|
||||||
|
[ServerFeature.JUKEBOX]: [1],
|
||||||
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
|
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -866,12 +867,12 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: albums.totalRecordCount,
|
totalRecordCount: albums.totalRecordCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getSongListCount: async ({ apiClientProps, query }) =>
|
getSongListCount: async ({ apiClientProps, query }) =>
|
||||||
NavidromeController.getSongList({
|
NavidromeController.getSongList({
|
||||||
apiClientProps,
|
apiClientProps,
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
|
|
||||||
getStreamUrl: SubsonicController.getStreamUrl,
|
getStreamUrl: SubsonicController.getStreamUrl,
|
||||||
getStructuredLyrics: SubsonicController.getStructuredLyrics,
|
getStructuredLyrics: SubsonicController.getStructuredLyrics,
|
||||||
getTagList: async (args) => {
|
getTagList: async (args) => {
|
||||||
@@ -1008,6 +1009,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
jukeboxControl: SubsonicController.jukeboxControl,
|
||||||
movePlaylistItem: async (args) => {
|
movePlaylistItem: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.user,
|
200: ssType._response.user,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
jukeboxControl: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'jukeboxControl.view',
|
||||||
|
query: ssType._parameters.jukeboxControl,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.jukeboxControl,
|
||||||
|
},
|
||||||
|
},
|
||||||
ping: {
|
ping: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'ping.view',
|
path: 'ping.view',
|
||||||
|
|||||||
@@ -1395,6 +1395,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
|
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
|
||||||
features.reportPlayback = [1];
|
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 };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||||
},
|
},
|
||||||
@@ -2052,6 +2068,25 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
name: res.body.user.username,
|
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 }) => {
|
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||||
query: {
|
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 { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||||
import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
||||||
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
|
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 { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||||
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
|
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 { toast } from '/@/shared/components/toast/toast';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const CODEC_PROBES = [
|
const CODEC_PROBES = [
|
||||||
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
||||||
|
|
||||||
@@ -329,6 +329,7 @@ const AudioPlayersContent = ({
|
|||||||
<>
|
<>
|
||||||
{playbackType === PlayerType.WEB && <WebPlayer />}
|
{playbackType === PlayerType.WEB && <WebPlayer />}
|
||||||
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
|
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
|
||||||
|
{playbackType === PlayerType.JUKEBOX && <JukeboxPlayer />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ const AudioPlayerTypeConfig = () => {
|
|||||||
value: PlayerType.LOCAL,
|
value: PlayerType.LOCAL,
|
||||||
},
|
},
|
||||||
{ label: 'Web', value: PlayerType.WEB },
|
{ label: 'Web', value: PlayerType.WEB },
|
||||||
|
{ label: 'Jukebox', value: PlayerType.JUKEBOX },
|
||||||
]}
|
]}
|
||||||
defaultValue={playbackSettings.type}
|
defaultValue={playbackSettings.type}
|
||||||
disabled={status === PlayerStatus.PLAYING}
|
disabled={status === PlayerStatus.PLAYING}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
SettingOption,
|
SettingOption,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} 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 { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||||
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { Select } from '/@/shared/components/select/select';
|
import { Select } from '/@/shared/components/select/select';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
|
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||||
|
|
||||||
@@ -91,22 +92,33 @@ export const AudioSettings = memo(() => {
|
|||||||
const status = usePlayerStatus();
|
const status = usePlayerStatus();
|
||||||
const playbackType = usePlaybackType();
|
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 audioDevices = useAudioDevices(playbackType);
|
||||||
const audioDeviceId =
|
const audioDeviceId =
|
||||||
playbackType === PlayerType.LOCAL ? settings.mpvAudioDeviceId : settings.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[] = [
|
const audioOptions: SettingOption[] = [
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={selectData}
|
||||||
{
|
|
||||||
disabled: !isElectron(),
|
|
||||||
label: 'MPV',
|
|
||||||
value: PlayerType.LOCAL,
|
|
||||||
},
|
|
||||||
{ label: 'Web', value: PlayerType.WEB },
|
|
||||||
]}
|
|
||||||
defaultValue={settings.type}
|
defaultValue={settings.type}
|
||||||
disabled={status === PlayerStatus.PLAYING}
|
disabled={status === PlayerStatus.PLAYING}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -115,10 +127,8 @@ export const AudioSettings = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.audioPlayer', {
|
description: t('setting.audioPlayer', { context: 'description' }),
|
||||||
context: 'description',
|
isHidden: !isElectron() && !isJukeboxSupported,
|
||||||
}),
|
|
||||||
isHidden: !isElectron(),
|
|
||||||
note: status === PlayerStatus.PLAYING ? t('common.playerMustBePaused') : undefined,
|
note: status === PlayerStatus.PLAYING ? t('common.playerMustBePaused') : undefined,
|
||||||
title: t('setting.audioPlayer'),
|
title: t('setting.audioPlayer'),
|
||||||
},
|
},
|
||||||
@@ -139,9 +149,7 @@ export const AudioSettings = memo(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.audioDevice', {
|
description: t('setting.audioDevice', { context: 'description' }),
|
||||||
context: 'description',
|
|
||||||
}),
|
|
||||||
isHidden: !isElectron(),
|
isHidden: !isElectron(),
|
||||||
title: t('setting.audioDevice'),
|
title: t('setting.audioDevice'),
|
||||||
},
|
},
|
||||||
@@ -156,9 +164,7 @@ export const AudioSettings = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.webAudio', {
|
description: t('setting.webAudio', { context: 'description' }),
|
||||||
context: 'description',
|
|
||||||
}),
|
|
||||||
isHidden: settings.type !== PlayerType.WEB,
|
isHidden: settings.type !== PlayerType.WEB,
|
||||||
note: t('common.restartRequired'),
|
note: t('common.restartRequired'),
|
||||||
title: t('setting.webAudio'),
|
title: t('setting.webAudio'),
|
||||||
@@ -174,9 +180,7 @@ export const AudioSettings = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.preservePitch', {
|
description: t('setting.preservePitch', { context: 'description' }),
|
||||||
context: 'description',
|
|
||||||
}),
|
|
||||||
isHidden: settings.type !== PlayerType.WEB,
|
isHidden: settings.type !== PlayerType.WEB,
|
||||||
title: t('setting.preservePitch'),
|
title: t('setting.preservePitch'),
|
||||||
},
|
},
|
||||||
@@ -186,16 +190,12 @@ export const AudioSettings = memo(() => {
|
|||||||
defaultChecked={settings.audioFadeOnStatusChange}
|
defaultChecked={settings.audioFadeOnStatusChange}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
playback: {
|
playback: { audioFadeOnStatusChange: e.currentTarget.checked },
|
||||||
audioFadeOnStatusChange: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.audioFadeOnStatusChange', {
|
description: t('setting.audioFadeOnStatusChange', { context: 'description' }),
|
||||||
context: 'description',
|
|
||||||
}),
|
|
||||||
title: t('setting.audioFadeOnStatusChange'),
|
title: t('setting.audioFadeOnStatusChange'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -805,6 +805,53 @@ const reportPlaybackParameters = z.object({
|
|||||||
|
|
||||||
const reportPlayback = z.null();
|
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 = {
|
export const ssType = {
|
||||||
_body: {
|
_body: {
|
||||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
@@ -834,6 +881,7 @@ export const ssType = {
|
|||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
getTranscodeDecision: transcodeDecisionParameters,
|
getTranscodeDecision: transcodeDecisionParameters,
|
||||||
getTranscodeStream: getTranscodeStreamParameters,
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
|
jukeboxControl: jukeboxControlParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
reportPlayback: reportPlaybackParameters,
|
reportPlayback: reportPlaybackParameters,
|
||||||
@@ -882,6 +930,9 @@ export const ssType = {
|
|||||||
getStarred,
|
getStarred,
|
||||||
getTranscodeDecision,
|
getTranscodeDecision,
|
||||||
internetRadioStation,
|
internetRadioStation,
|
||||||
|
jukeboxControl,
|
||||||
|
jukeboxPlaylist,
|
||||||
|
jukeboxStatus,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
ping,
|
ping,
|
||||||
playlist,
|
playlist,
|
||||||
|
|||||||
@@ -1540,6 +1540,7 @@ export type ControllerEndpoint = {
|
|||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
|
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||||
@@ -1705,6 +1706,9 @@ export type InternalControllerEndpoint = {
|
|||||||
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
||||||
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
||||||
|
jukeboxControl?: (
|
||||||
|
args: ReplaceApiClientProps<JukeboxControlArgs>,
|
||||||
|
) => Promise<JukeboxControlResponse>;
|
||||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||||
removeFromPlaylist: (
|
removeFromPlaylist: (
|
||||||
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
||||||
@@ -1737,6 +1741,54 @@ export type InternalControllerEndpoint = {
|
|||||||
) => Promise<UploadPlaylistImageResponse>;
|
) => 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 = {
|
export type LyricGetQuery = {
|
||||||
remoteSongId: string;
|
remoteSongId: string;
|
||||||
remoteSource: LyricSource;
|
remoteSource: LyricSource;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export enum ServerFeature {
|
|||||||
ARTIST_IMAGE_UPLOAD = 'artistImageUpload',
|
ARTIST_IMAGE_UPLOAD = 'artistImageUpload',
|
||||||
BFR = 'bfr',
|
BFR = 'bfr',
|
||||||
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
|
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
|
||||||
|
JUKEBOX = 'jukebox',
|
||||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||||
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export enum PlayerStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum PlayerType {
|
export enum PlayerType {
|
||||||
|
JUKEBOX = 'jukebox',
|
||||||
LOCAL = 'local',
|
LOCAL = 'local',
|
||||||
WEB = 'web',
|
WEB = 'web',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user