provide transcoding support

This commit is contained in:
Kendall Garner 2024-09-01 08:26:30 -07:00
parent da95a644c8
commit 528bef01f0
No known key found for this signature in database
GPG key ID: 18D2767419676C87
24 changed files with 347 additions and 69 deletions

View file

@ -632,6 +632,13 @@
"themeDark_description": "sets the dark theme to use for the application",
"themeLight": "theme (light)",
"themeLight_description": "sets the light theme to use for the application",
"transcodeNote": "takes effect after 1 (web) - 2 (mpv) songs",
"transcode": "enable transcoding",
"transcode_description": "enables transcoding to different formats",
"transcodeBitrate": "bitrate to transcode",
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",

View file

@ -5,7 +5,6 @@ import { app, ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { getMainWindow, sendToastToRenderer } from '../../../main';
import { PlayerData } from '/@/renderer/store';
import { createLog, isWindows } from '../../../utils';
import { store } from '../settings';
@ -315,8 +314,8 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current?.id && !data.queue.next?.id) {
ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, pause?: boolean) => {
if (!current && !next) {
try {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
@ -327,15 +326,15 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
}
try {
if (data.queue.current?.streamUrl) {
if (current) {
try {
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
await getMpvInstance()?.load(current, 'replace');
} catch (error) {
await getMpvInstance()?.play();
}
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (next) {
await getMpvInstance()?.load(next, 'append');
}
}
@ -351,7 +350,7 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
try {
const size = await getMpvInstance()?.getPlaylistSize();
@ -363,8 +362,8 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
await getMpvInstance()?.playlistRemove(1);
}
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set play queue` }, err);
@ -372,7 +371,7 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
});
// Sets the next song in the queue when reaching the end of the queue
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
ipcMain.on('player-auto-next', async (_event, url?: string) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
@ -383,8 +382,8 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
getMpvInstance()?.pause();
});
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to load next song` }, err);

View file

@ -25,8 +25,8 @@ const setProperties = (data: Record<string, any>) => {
ipcRenderer.send('player-set-properties', data);
};
const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data);
const autoNext = (url?: string) => {
ipcRenderer.send('player-auto-next', url);
};
const currentTime = () => {
@ -61,12 +61,12 @@ const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
const setQueue = (current?: string, next?: string, pause?: boolean) => {
ipcRenderer.send('player-set-queue', current, next, pause);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
const setQueueNext = (url?: string) => {
ipcRenderer.send('player-set-queue-next', url);
};
const stop = () => {

View file

@ -59,6 +59,7 @@ import type {
ShareItemResponse,
MoveItemArgs,
DownloadArgs,
TranscodingArgs,
} from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
@ -102,6 +103,7 @@ export type ControllerEndpoint = Partial<{
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getTranscodingUrl: (args: TranscodingArgs) => string;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
@ -152,6 +154,7 @@ const endpoints: ApiController = {
getSongList: jfController.getSongList,
getStructuredLyrics: undefined,
getTopSongs: jfController.getTopSongList,
getTranscodingUrl: jfController.getTranscodingUrl,
getUserList: undefined,
movePlaylistItem: jfController.movePlaylistItem,
removeFromPlaylist: jfController.removeFromPlaylist,
@ -194,6 +197,7 @@ const endpoints: ApiController = {
getSongList: ndController.getSongList,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getTranscodingUrl: ssController.getTranscodingUrl,
getUserList: ndController.getUserList,
movePlaylistItem: ndController.movePlaylistItem,
removeFromPlaylist: ndController.removeFromPlaylist,
@ -233,6 +237,7 @@ const endpoints: ApiController = {
getSongList: undefined,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getTranscodingUrl: ssController.getTranscodingUrl,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
@ -568,6 +573,15 @@ const getDownloadUrl = (args: DownloadArgs) => {
)?.(args);
};
const getTranscodingUrl = (args: TranscodingArgs) => {
return (
apiController(
'getTranscodingUrl',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTranscodingUrl']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
@ -594,6 +608,7 @@ export const controller = {
getSongList,
getStructuredLyrics,
getTopSongList,
getTranscodingUrl,
getUserList,
movePlaylistItem,
removeFromPlaylist,

View file

@ -55,6 +55,7 @@ import {
Song,
MoveItemArgs,
DownloadArgs,
TranscodingArgs,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
@ -1050,6 +1051,20 @@ const getDownloadUrl = (args: DownloadArgs) => {
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
};
const getTranscodingUrl = (args: TranscodingArgs) => {
const { base, format, bitrate } = args.query;
let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http');
if (format) {
url = url.replace('audioCodec=aac', `audioCodec=${format}`);
url = url.replace('transcodingContainer=ts', `transcodingContainer=${format}`);
}
if (bitrate !== undefined) {
url += `&maxStreamingBitrate=${bitrate * 1000}`;
}
return url;
};
export const jfController = {
addToPlaylist,
authenticate,
@ -1075,6 +1090,7 @@ export const jfController = {
getSongDetail,
getSongList,
getTopSongList,
getTranscodingUrl,
movePlaylistItem,
removeFromPlaylist,
scrobble,

View file

@ -34,7 +34,7 @@ const getStreamUrl = (args: {
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=hls'
'&transcodingProtocol=http'
);
};

View file

@ -8,7 +8,7 @@ import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { toast } from '/@/renderer/components/toast';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;

View file

@ -28,6 +28,7 @@ import {
SimilarSongsArgs,
Song,
DownloadArgs,
TranscodingArgs,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features-types';
@ -495,6 +496,19 @@ const getDownloadUrl = (args: DownloadArgs) => {
);
};
const getTranscodingUrl = (args: TranscodingArgs) => {
const { base, format, bitrate } = args.query;
let url = base;
if (format) {
url += `&format=${format}`;
}
if (bitrate !== undefined) {
url += `&maxBitRate=${bitrate}`;
}
return url;
};
export const ssController = {
authenticate,
createFavorite,
@ -506,6 +520,7 @@ export const ssController = {
getSimilarSongs,
getStructuredLyrics,
getTopSongList,
getTranscodingUrl,
removeFavorite,
scrobble,
search3,

View file

@ -1211,3 +1211,13 @@ export type DownloadQuery = {
export type DownloadArgs = {
query: DownloadQuery;
} & BaseEndpointArgs;
export type TranscodingQuery = {
base: string;
bitrate?: number;
format?: string;
};
export type TranscodingArgs = {
query: TranscodingQuery;
} & BaseEndpointArgs;

View file

@ -3,7 +3,7 @@ import isElectron from 'is-electron';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features-types';

View file

@ -28,6 +28,7 @@ import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -185,7 +186,7 @@ export const App = () => {
utils.onRestoreQueue((_event: any, data) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueue(playerData, true);
setQueue(playerData, true);
}
updateSong(playerData.current.song);
});

View file

@ -1,4 +1,12 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import {
useImperativeHandle,
forwardRef,
useRef,
useState,
useCallback,
useEffect,
useMemo,
} from 'react';
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player/lazy';
@ -10,16 +18,17 @@ import {
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useSpeed } from '/@/renderer/store';
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
import { api } from '/@/renderer/api';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
player1: Song;
player2: Song;
player1?: Song;
player2?: Song;
status: PlayerStatus;
volume: number;
}
@ -48,6 +57,44 @@ type WebAudio = {
const EMPTY_SOURCE =
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): string | null => {
const prior = useRef(['', '']);
return useMemo(() => {
if (song?.serverId) {
// If we are the current track, we do not want a transcoding
// reconfiguration to force a restart.
if (current && prior.current[0] === song.uniqueId) {
return prior.current[1];
}
if (!transcode.enabled) {
// transcoding disabled; save the result
prior.current = [song.uniqueId, song.streamUrl];
return song.streamUrl;
}
const result = api.controller.getTranscodingUrl({
apiClientProps: {
server: getServerById(song.serverId),
},
query: {
base: song.streamUrl,
...transcode,
},
})!;
// transcoding enabled; save the updated result
prior.current = [song.uniqueId, result];
return result;
}
// no track; clear result
prior.current = ['', ''];
return null;
}, [current, song?.uniqueId, song?.serverId, song?.streamUrl, transcode]);
};
export const AudioPlayer = forwardRef(
(
{
@ -72,6 +119,10 @@ export const AudioPlayer = forwardRef(
const useWebAudio = useSettingsStore((state) => state.playback.webAudio);
const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed();
const { transcode } = usePlaybackSettings();
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
@ -374,11 +425,11 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl || EMPTY_SOURCE}
url={stream1 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
// If there is no stream url, we do not need to handle when the audio finishes
onEnded={player1?.streamUrl ? handleOnEnded : undefined}
onEnded={stream1 ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
@ -394,10 +445,10 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl || EMPTY_SOURCE}
url={stream2 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
onEnded={player2?.streamUrl ? handleOnEnded : undefined}
onEnded={stream2 ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}

View file

@ -65,6 +65,7 @@ import { ItemDetailsModal } from '/@/renderer/features/item-details/components/i
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { controller } from '/@/renderer/api/controller';
import { api } from '/@/renderer/api';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
type ContextMenuContextProps = {
closeContextMenu: () => void;
@ -92,7 +93,6 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const utils = isElectron() ? window.electron.utils : null;
export interface ContextMenuProviderProps {
@ -610,7 +610,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const playerData = moveToBottomOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToBottomOfQueue, playbackType]);
@ -621,7 +621,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const playerData = moveToTopOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
@ -651,9 +651,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
} else {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
}
@ -687,7 +687,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
},
query: { albumArtistIds: item.albumArtistIds, songId: item.id },
});
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
if (songs) {
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
}
}, [ctx, handlePlayQueueAdd]);
const handleDownload = useCallback(() => {

View file

@ -19,6 +19,7 @@ import { usePlaybackType } from '/@/renderer/store/settings.store';
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -45,7 +46,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = moveToBottomOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
};
@ -57,7 +58,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = moveToTopOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
};
@ -72,9 +73,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
} else {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
}
@ -87,7 +88,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = clearQueue();
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
mpvPlayer!.pause();
}
@ -101,7 +102,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = shuffleQueue();
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
};

View file

@ -38,6 +38,7 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
import { useAppFocus } from '/@/renderer/hooks';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -86,7 +87,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume);
mpvPlayer!.setQueue(playerData, false);
setQueue(playerData, false);
} else {
const player =
playerData.current.player === 1
@ -117,7 +118,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
if (type === 'sideDrawerQueue') {

View file

@ -137,7 +137,9 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
if (!isElectron() || playbackType === PlaybackType.WEB) {
// Update twice a second for slightly better performance
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
if (currentPlayerRef) {
setCurrentTime(currentPlayerRef.getCurrentTime());
}
}, 500);
}
}

View file

@ -17,6 +17,7 @@ import debounce from 'lodash/debounce';
import { toast } from '/@/renderer/components';
import { useTranslation } from 'react-i18next';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { setAutoNext, setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
@ -131,30 +132,30 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (shuffleStatus === PlayerShuffle.NONE) {
const playerData = setShuffle(PlayerShuffle.TRACK);
remote?.updateShuffle(true);
return mpvPlayer?.setQueueNext(playerData);
return setQueueNext(playerData);
}
const playerData = setShuffle(PlayerShuffle.NONE);
remote?.updateShuffle(false);
return mpvPlayer?.setQueueNext(playerData);
return setQueueNext(playerData);
}, [setShuffle, shuffleStatus]);
const handleToggleRepeat = useCallback(() => {
if (repeatStatus === PlayerRepeat.NONE) {
const playerData = setRepeat(PlayerRepeat.ALL);
remote?.updateRepeat(PlayerRepeat.ALL);
return mpvPlayer?.setQueueNext(playerData);
return setQueueNext(playerData);
}
if (repeatStatus === PlayerRepeat.ALL) {
const playerData = setRepeat(PlayerRepeat.ONE);
remote?.updateRepeat(PlayerRepeat.ONE);
return mpvPlayer?.setQueueNext(playerData);
return setQueueNext(playerData);
}
const playerData = setRepeat(PlayerRepeat.NONE);
remote?.updateRepeat(PlayerRepeat.NONE);
return mpvPlayer?.setQueueNext(playerData);
return setQueueNext(playerData);
}, [repeatStatus, setRepeat]);
const checkIsLastTrack = useCallback(() => {
@ -172,7 +173,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
const playerData = autoNext();
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
setAutoNext(playerData);
play();
},
web: () => {
@ -186,12 +187,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
setQueue(playerData, true);
pause();
} else {
const playerData = autoNext();
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
setAutoNext(playerData);
play();
}
},
@ -211,7 +212,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
const playerData = autoNext();
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
setAutoNext(playerData);
play();
},
web: () => {
@ -257,7 +258,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
const playerData = next();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
},
web: () => {
const playerData = next();
@ -270,12 +271,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
setQueue(playerData, true);
pause();
} else {
const playerData = next();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
}
},
web: () => {
@ -297,7 +298,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (!isLastTrack) {
const playerData = next();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
}
},
web: () => {
@ -358,11 +359,11 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (!isFirstTrack) {
const playerData = previous();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
} else {
const playerData = setCurrentIndex(queue.length - 1);
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
}
},
web: () => {
@ -383,12 +384,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (isFirstTrack) {
const playerData = setCurrentIndex(0);
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
setQueue(playerData, true);
pause();
} else {
const playerData = previous();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
}
},
web: () => {
@ -407,7 +408,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
const playerData = previous();
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
setQueue(playerData);
},
web: () => {
const playerData = previous();

View file

@ -25,6 +25,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey;
@ -180,9 +181,9 @@ export const useHandlePlayQueueAdd = () => {
if (playType === Play.NOW || !hadSong) {
mpvPlayer!.pause();
mpvPlayer!.setQueue(playerData, false);
setQueue(playerData, false);
} else {
mpvPlayer!.setQueueNext(playerData);
setQueueNext(playerData);
}
} else {
const player =

View file

@ -10,8 +10,7 @@ import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
@ -62,7 +61,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
setSettings({ playback: { ...settings, type: e as PlaybackType } });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
mpvPlayer!.setQueue(queueData);
setQueue(queueData);
}
}}
/>

View file

@ -6,6 +6,7 @@ import isElectron from 'is-electron';
import { LyricSettings } from '/@/renderer/features/settings/components/playback/lyric-settings';
import { useSettingsStore } from '/@/renderer/store';
import { PlaybackType } from '/@/renderer/types';
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
const MpvSettings = lazy(() =>
import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => {
@ -28,6 +29,7 @@ export const PlaybackTab = () => {
<Stack spacing="md">
<AudioSettings hasFancyAudio={hasFancyAudio} />
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
<TranscodeSettings />
<ScrobbleSettings />
<LyricSettings />
</Stack>

View file

@ -0,0 +1,89 @@
import { NumberInput, Switch, TextInput } from '/@/renderer/components';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { SettingOption, SettingsSection } from '../settings-section';
import { useTranslation } from 'react-i18next';
export const TranscodeSettings = () => {
const { t } = useTranslation();
const { transcode } = usePlaybackSettings();
const { setTranscodingConfig } = useSettingsStoreActions();
const note = t('setting.transcodeNote', { postProcess: 'sentenceCase' });
const transcodeOptions: SettingOption[] = [
{
control: (
<Switch
aria-label="Toggle transcode"
defaultChecked={transcode.enabled}
onChange={(e) => {
setTranscodingConfig({
...transcode,
enabled: e.currentTarget.checked,
});
}}
/>
),
description: t('setting.transcode', {
context: 'description',
postProcess: 'sentenceCase',
}),
note,
title: t('setting.transcode', { postProcess: 'sentenceCase' }),
},
{
control: (
<NumberInput
aria-label="Transcode bitrate"
defaultValue={transcode.bitrate}
min={0}
placeholder="mp3, opus"
w={100}
onBlur={(e) => {
setTranscodingConfig({
...transcode,
bitrate: e.currentTarget.value
? Number(e.currentTarget.value)
: undefined,
});
}}
/>
),
description: t('setting.transcodeBitrate', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !transcode.enabled,
note,
title: t('setting.transcodeBitrate', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
aria-label="transcoding format"
defaultValue={transcode.format}
width={100}
onBlur={(e) => {
setTranscodingConfig({
...transcode,
format: e.currentTarget.value || undefined,
});
}}
/>
),
description: t('setting.transcodeFormat', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !transcode.enabled,
note,
title: t('setting.transcodeFormat', { postProcess: 'sentenceCase' }),
},
];
return (
<SettingsSection
divider
options={transcodeOptions}
/>
);
};

View file

@ -992,6 +992,9 @@ export const usePlayerStore = create<PlayerSlice>()(
},
repeat: PlayerRepeat.NONE,
shuffle: PlayerShuffle.NONE,
transcode: {
enabled: false,
},
volume: 50,
})),
{ name: 'store_player' },

View file

@ -177,6 +177,12 @@ export enum GenreTarget {
TRACK = 'track',
}
export type TranscodingConfig = {
bitrate?: number;
enabled: boolean;
format?: string;
};
export interface SettingsState {
css: {
content: string;
@ -264,6 +270,7 @@ export interface SettingsState {
scrobbleAtPercentage: number;
};
style: PlaybackStyle;
transcode: TranscodingConfig;
type: PlaybackType;
webAudio: boolean;
};
@ -300,6 +307,7 @@ export interface SettingsSlice extends SettingsState {
setSettings: (data: Partial<SettingsState>) => void;
setSidebarItems: (items: SidebarItemType[]) => void;
setTable: (type: TableType, data: DataTableProps) => void;
setTranscodingConfig: (config: TranscodingConfig) => void;
toggleContextMenuItem: (item: ContextMenuItemType) => void;
toggleSidebarCollapseShare: () => void;
};
@ -439,6 +447,9 @@ const initialState: SettingsState = {
scrobbleAtPercentage: 75,
},
style: PlaybackStyle.GAPLESS,
transcode: {
enabled: false,
},
type: PlaybackType.WEB,
webAudio: true,
},
@ -662,6 +673,11 @@ export const useSettingsStore = create<SettingsSlice>()(
state.tables[type] = data;
});
},
setTranscodingConfig: (config) => {
set((state) => {
state.playback.transcode = config;
});
},
toggleContextMenuItem: (item: ContextMenuItemType) => {
set((state) => {
state.general.disabledContextMenu[item] =

View file

@ -0,0 +1,47 @@
import isElectron from 'is-electron';
import { getServerById, PlayerData, useSettingsStore } from '/@/renderer/store';
import type { QueueSong } from '/@/renderer/api/types';
import { api } from '/@/renderer/api';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const modifyUrl = (song: QueueSong): string => {
const transcode = useSettingsStore.getState().playback.transcode;
if (transcode.enabled) {
const streamUrl = api.controller.getTranscodingUrl({
apiClientProps: {
server: getServerById(song.serverId),
},
query: {
base: song.streamUrl,
...transcode,
},
})!;
return streamUrl;
}
return song.streamUrl;
};
export const setQueue = (data: PlayerData, pause?: boolean): void => {
const current = data.queue.current ? modifyUrl(data.queue.current) : undefined;
const next = data.queue.next ? modifyUrl(data.queue.next) : undefined;
mpvPlayer?.setQueue(current, next, pause);
};
export const setQueueNext = (data: PlayerData): void => {
if (data.queue.next) {
mpvPlayer?.setQueueNext(modifyUrl(data.queue.next));
} else {
mpvPlayer?.setQueueNext(undefined);
}
};
export const setAutoNext = (data: PlayerData): void => {
if (data.queue.next) {
mpvPlayer?.autoNext(modifyUrl(data.queue.next));
} else {
mpvPlayer?.autoNext(undefined);
}
};