diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index e8d450ed2..1446cfcfe 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -514,6 +514,20 @@ export const controller: GeneralController = { query: mergeMusicFolderId(args.query, server), }); }, + getStreamUrl(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`, + ); + } + + return apiController( + 'getStreamUrl', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, getStructuredLyrics(args) { const server = getServerById(args.apiClientProps.serverId); @@ -556,20 +570,6 @@ export const controller: GeneralController = { server.type, )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); }, - getTranscodingUrl(args) { - const server = getServerById(args.apiClientProps.serverId); - - if (!server) { - throw new Error( - `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTranscodingUrl`, - ); - } - - return apiController( - 'getTranscodingUrl', - server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); - }, getUserList(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index f0893afe4..eaa82c1ed 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -552,7 +552,7 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; @@ -602,7 +602,7 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: res.body.Items.length || 0, }; @@ -647,7 +647,7 @@ export const JellyfinController: InternalControllerEndpoint = { if (res.status === 200 && res.body.Items.length) { const results = res.body.Items.reduce((acc, song) => { if (song.Id !== query.songId) { - acc.push(jfNormalize.song(song, apiClientProps.server, '')); + acc.push(jfNormalize.song(song, apiClientProps.server)); } return acc; @@ -676,7 +676,7 @@ export const JellyfinController: InternalControllerEndpoint = { return mix.body.Items.reduce((acc, song) => { if (song.Id !== query.songId) { - acc.push(jfNormalize.song(song, apiClientProps.server, '')); + acc.push(jfNormalize.song(song, apiClientProps.server)); } return acc; @@ -696,7 +696,7 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get song detail'); } - return jfNormalize.song(res.body, apiClientProps.server, ''); + return jfNormalize.song(res.body, apiClientProps.server); }, getSongList: async (args) => { const { apiClientProps, query } = args; @@ -809,7 +809,7 @@ export const JellyfinController: InternalControllerEndpoint = { return { items: items.map((item) => - jfNormalize.song(item, apiClientProps.server, '', query.imageSize), + jfNormalize.song(item, apiClientProps.server, query.imageSize), ), startIndex: query.startIndex, totalRecordCount, @@ -820,6 +820,39 @@ export const JellyfinController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getStreamUrl: ({ apiClientProps: { server }, query }) => { + const { bitrate, format, id, transcode } = query; + const deviceId = ''; + + let url = + `${server?.url}/audio` + + `/${id}/universal` + + `?userId=${server?.userId}` + + `&deviceId=${deviceId}` + + '&audioCodec=aac' + + `&apiKey=${server?.credential}` + + `&playSessionId=${deviceId}` + + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg'; + + if (transcode) { + // Some format appears to be required. Fall back to trusty MP3 if not specified + // Otherwise, ffmpeg appears to crash + const realFormat = format || 'mp3'; + + url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`; + url = url.replace('audioCodec=aac', `audioCodec=${realFormat}`); + url = url.replace( + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg', + `&container=${realFormat}`, + ); + + if (bitrate !== undefined) { + url += `&maxStreamingBitrate=${bitrate * 1000}`; + } + } + + return url; + }, getTags: async (args) => { const { apiClientProps, query } = args; @@ -873,24 +906,11 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; }, - getTranscodingUrl: (args) => { - const { base, bitrate, format } = 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; - }, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; @@ -1082,7 +1102,7 @@ export const JellyfinController: InternalControllerEndpoint = { jfNormalize.albumArtist(item, apiClientProps.server), ), albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)), - songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server)), }; }, updatePlaylist: async (args) => { diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 410d4165c..154fbba9d 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -605,6 +605,7 @@ export const NavidromeController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getStreamUrl: SubsonicController.getStreamUrl, getStructuredLyrics: SubsonicController.getStructuredLyrics, getTags: async (args) => { const { apiClientProps } = args; @@ -646,7 +647,6 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, getTopSongs: SubsonicController.getTopSongs, - getTranscodingUrl: SubsonicController.getTranscodingUrl, getUserList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b4ec80ba0..0963cf7e5 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1246,6 +1246,21 @@ export const SubsonicController: InternalControllerEndpoint = { return totalRecordCount; }, + getStreamUrl: ({ apiClientProps: { server }, query }) => { + const { bitrate, format, id, transcode } = query; + let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`; + + if (transcode) { + if (format) { + url += `&format=${format}`; + } + if (bitrate !== undefined) { + url += `&maxBitRate=${bitrate}`; + } + } + + return url; + }, getStructuredLyrics: async (args) => { const { apiClientProps, query } = args; @@ -1311,18 +1326,6 @@ export const SubsonicController: InternalControllerEndpoint = { totalRecordCount: res.body.topSongs?.song?.length || 0, }; }, - getTranscodingUrl: (args) => { - const { base, bitrate, format } = args.query; - let url = base; - if (format) { - url += `&format=${format}`; - } - if (bitrate !== undefined) { - url += `&maxBitRate=${bitrate}`; - } - - return url; - }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { diff --git a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx new file mode 100644 index 000000000..03544c7ec --- /dev/null +++ b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx @@ -0,0 +1,49 @@ +import { useMemo, useRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { TranscodingConfig } from '/@/renderer/store'; +import { QueueSong } from '/@/shared/types/domain-types'; + +export function useSongUrl( + song: QueueSong | undefined, + current: boolean, + transcode: TranscodingConfig, +): string | undefined { + 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]; + } + + const url = api.controller.getStreamUrl({ + apiClientProps: { serverId: song._serverId }, + query: { + bitrate: transcode.bitrate, + format: transcode.format, + id: song.id, + transcode: transcode.enabled, + }, + }); + + // transcoding enabled; save the updated result + prior.current = [song._uniqueId, url]; + return url; + } + + // no track; clear result + prior.current = ['', '']; + return undefined; + }, [ + song?._serverId, + song?._uniqueId, + song?.id, + current, + transcode.bitrate, + transcode.format, + transcode.enabled, + ]); +} diff --git a/src/renderer/features/player/audio-player/index.tsx b/src/renderer/features/player/audio-player/index.tsx deleted file mode 100644 index 0793fae0d..000000000 --- a/src/renderer/features/player/audio-player/index.tsx +++ /dev/null @@ -1,493 +0,0 @@ -import type { QueueSong, Song } from '/@/shared/types/domain-types'; -import type { CrossfadeStyle } from '/@/shared/types/types'; -import type { ReactPlayerProps } from 'react-player'; - -import isElectron from 'is-electron'; -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; -import ReactPlayer from 'react-player/lazy'; - -import { api } from '/@/renderer/api'; -import { - crossfadeHandler, - gaplessHandler, -} from '/@/renderer/features/player/audio-player/utils/list-handlers'; -import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; -import { - TranscodingConfig, - usePlaybackSettings, - usePlayerSpeed, - useSettingsStore, - useSettingsStoreActions, -} from '/@/renderer/store'; -import { toast } from '/@/shared/components/toast/toast'; -import { PlayerStatus, PlayerStyle } from '/@/shared/types/types'; - -export type AudioPlayerProgress = { - loaded: number; - loadedSeconds: number; - played: number; - playedSeconds: number; -}; - -interface AudioPlayerProps extends ReactPlayerProps { - autoNext: () => void; - crossfadeDuration: number; - crossfadeStyle: CrossfadeStyle; - currentPlayer: 1 | 2; - muted: boolean; - playbackStyle: PlayerStyle; - player1?: Song; - player2?: Song; - status: PlayerStatus; - volume: number; -} - -const getDuration = (ref: any) => { - return ref.current?.player?.player?.player?.duration; -}; - -// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393 -// This is used so that the player will always have an