diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 949de67b..4d9428c5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 1a7e40a5..67152b65 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -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); diff --git a/src/main/preload/mpv-player.ts b/src/main/preload/mpv-player.ts index 78108e29..e0be6fd9 100644 --- a/src/main/preload/mpv-player.ts +++ b/src/main/preload/mpv-player.ts @@ -25,8 +25,8 @@ const setProperties = (data: Record) => { 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 = () => { diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index d3cf0549..cc3e3037 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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; getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; + getTranscodingUrl: (args: TranscodingArgs) => string; getUserList: (args: UserListArgs) => Promise; movePlaylistItem: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; @@ -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, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 8577d62c..8ad47305 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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, diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index fc7e1470..fbb611db 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -34,7 +34,7 @@ const getStreamUrl = (args: { `&playSessionId=${deviceId}` + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' + '&transcodingContainer=ts' + - '&transcodingProtocol=hls' + '&transcodingProtocol=http' ); }; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 90477420..251d5bb9 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -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; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index ac2e9ed9..b575a8dc 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index b0dee32d..a96af4f8 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -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; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 314185ae..22c836f6 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -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'; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 13b1a3fc..62804eba 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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); }); diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 3db25a41..8e70c84a 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -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(null); const [player1Source, setPlayer1Source] = useState( @@ -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 } diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index d1a348b6..2b8b736c 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -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(() => { diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index 6486cd72..e69e25fb 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -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); } }; diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 4c06c387..c35e0572 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -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) => { 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) => { const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId); if (playbackType === PlaybackType.LOCAL) { - mpvPlayer!.setQueueNext(playerData); + setQueueNext(playerData); } if (type === 'sideDrawerQueue') { diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index 88a48698..f93e2b21 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -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); } } diff --git a/src/renderer/features/player/hooks/use-center-controls.ts b/src/renderer/features/player/hooks/use-center-controls.ts index 159acfc4..9d275486 100644 --- a/src/renderer/features/player/hooks/use-center-controls.ts +++ b/src/renderer/features/player/hooks/use-center-controls.ts @@ -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(); diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index 69e586e3..6f459829 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -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 = diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index d1d7e79b..852b4246 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -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); } }} /> diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index 404c9c51..c3d0cba9 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -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 = () => { }>{hasFancyAudio && } + diff --git a/src/renderer/features/settings/components/playback/transcode-settings.tsx b/src/renderer/features/settings/components/playback/transcode-settings.tsx new file mode 100644 index 00000000..0665d6bb --- /dev/null +++ b/src/renderer/features/settings/components/playback/transcode-settings.tsx @@ -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: ( + { + setTranscodingConfig({ + ...transcode, + enabled: e.currentTarget.checked, + }); + }} + /> + ), + description: t('setting.transcode', { + context: 'description', + postProcess: 'sentenceCase', + }), + note, + title: t('setting.transcode', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + 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: ( + { + 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 ( + + ); +}; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index cba37496..e9a89b73 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -992,6 +992,9 @@ export const usePlayerStore = create()( }, repeat: PlayerRepeat.NONE, shuffle: PlayerShuffle.NONE, + transcode: { + enabled: false, + }, volume: 50, })), { name: 'store_player' }, diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 9c5180e1..ecd8f66b 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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) => 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()( state.tables[type] = data; }); }, + setTranscodingConfig: (config) => { + set((state) => { + state.playback.transcode = config; + }); + }, toggleContextMenuItem: (item: ContextMenuItemType) => { set((state) => { state.general.disabledContextMenu[item] = diff --git a/src/renderer/utils/set-transcoded-queue-data.ts b/src/renderer/utils/set-transcoded-queue-data.ts new file mode 100644 index 00000000..0fb6b30e --- /dev/null +++ b/src/renderer/utils/set-transcoded-queue-data.ts @@ -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); + } +};