mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-13 14:10:04 +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,
|
||||
)?.(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'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -158,6 +158,7 @@ export enum PlayerStyle {
|
||||
}
|
||||
|
||||
export enum PlayerType {
|
||||
JUKEBOX = 'jukebox',
|
||||
LOCAL = 'local',
|
||||
WEB = 'web',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user