Compare commits

...

23 Commits

Author SHA1 Message Date
jeffvli a137de612a fix crossfade settings to update player store 2025-11-06 20:30:33 -08:00
jeffvli ae9a8e6d08 move mpv initialization into player implementation 2025-11-06 20:24:01 -08:00
jeffvli 9c3053608d re-add conditional render to ReactPlayer components which causes crossfade error 2025-11-06 20:23:22 -08:00
jeffvli d6ea97fa2a move remote init into hook 2025-11-06 18:59:42 -08:00
jeffvli 578b00fe3d lint 2025-11-06 18:42:43 -08:00
jeffvli f43f696f54 fix ipc in remote hook 2025-11-06 18:42:43 -08:00
jeffvli 4a81d7b249 add useRemote to playerbar 2025-11-06 18:42:43 -08:00
jeffvli 88fbc22923 add new player remote hook 2025-11-06 18:42:43 -08:00
jeffvli daef3b31fe add subscribers for player repeat and shuffle 2025-11-06 18:42:43 -08:00
jeffvli 7142017c26 support offset for player skip 2025-11-06 18:42:43 -08:00
jeffvli f30a466fb2 re-implement mpv with new player 2025-11-06 18:42:43 -08:00
jeffvli f2e3e7a74e add volume wheel 2025-11-06 18:42:43 -08:00
jeffvli 83efd6e8c5 refactor windowbar to prevent unnecessary renders 2025-11-06 18:42:43 -08:00
jeffvli 515496ab85 re-implement playerbar controls 2025-11-06 18:42:43 -08:00
jeffvli 16b99ef597 add usePlayer hook 2025-11-06 18:39:09 -08:00
jeffvli b607c57f59 rename internal types in domain models 2025-11-06 18:39:09 -08:00
jeffvli 9f0a8f2bae begin implementing player context 2025-11-06 18:39:09 -08:00
jeffvli 0e384a6302 reorganize 2025-11-06 18:39:09 -08:00
jeffvli 0c7cec9f4f fix table row styles listener 2025-11-06 18:39:09 -08:00
jeffvli f30a706eef rework player events to use properties object instead of flat value 2025-11-06 18:39:09 -08:00
jeffvli a980a8de0b add new web player implementation 2025-11-06 18:39:09 -08:00
jeffvli 9abda23a4a temporarily remove old player implementations to prevent broken app 2025-11-06 18:39:09 -08:00
jeffvli 4e3a3742a5 rework player store and add global player context 2025-11-06 18:38:32 -08:00
76 changed files with 5947 additions and 4028 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
import { isMacOS, isWindows } from '../../../utils'; import { isMacOS, isWindows } from '../../../utils';
import { store } from '../settings'; import { store } from '../settings';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
export const enableMediaKeys = (window: BrowserWindow | null) => { export const enableMediaKeys = (window: BrowserWindow | null) => {
if (isMacOS()) { if (isMacOS()) {
@@ -26,9 +26,9 @@ export const enableMediaKeys = (window: BrowserWindow | null) => {
} }
const enableWindowsMediaSession = store.get('mediaSession', false) as boolean; const enableWindowsMediaSession = store.get('mediaSession', false) as boolean;
const playbackType = store.get('playbackType', PlaybackType.WEB) as PlaybackType; const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
if (!enableWindowsMediaSession || !isWindows() || playbackType !== PlaybackType.WEB) { if (!enableWindowsMediaSession || !isWindows() || playbackType !== PlayerType.WEB) {
globalShortcut.register('MediaStop', () => { globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop'); window?.webContents.send('renderer-player-stop');
}); });
+4 -4
View File
@@ -382,7 +382,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
getMainWindow()?.webContents.send('request-favorite', { getMainWindow()?.webContents.send('request-favorite', {
favorite, favorite,
id, id,
serverId: currentState.song.serverId, serverId: currentState.song._serverId,
}); });
} }
break; break;
@@ -443,7 +443,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
getMainWindow()?.webContents.send('request-rating', { getMainWindow()?.webContents.send('request-rating', {
id, id,
rating, rating,
serverId: currentState.song.serverId, serverId: currentState.song._serverId,
}); });
} }
break; break;
@@ -578,7 +578,7 @@ ipcMain.on('remote-username', (_event, username: string) => {
}); });
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => { ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentState.song?.serverId !== serverId) return; if (currentState.song?._serverId !== serverId) return;
const id = currentState.song.id; const id = currentState.song.id;
@@ -592,7 +592,7 @@ ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids:
}); });
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => { ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentState.song?.serverId !== serverId) return; if (currentState.song?._serverId !== serverId) return;
const id = currentState.song.id; const id = currentState.song.id;
+3 -3
View File
@@ -38,7 +38,7 @@ import {
} from './utils'; } from './utils';
import './features'; import './features';
import { PlaybackType, TitleTheme } from '/@/shared/types/types'; import { PlayerType, TitleTheme } from '/@/shared/types/types';
export default class AppUpdater { export default class AppUpdater {
constructor() { constructor() {
@@ -549,9 +549,9 @@ async function createWindow(first = true): Promise<void> {
} }
const enableWindowsMediaSession = store.get('mediaSession', false) as boolean; const enableWindowsMediaSession = store.get('mediaSession', false) as boolean;
const playbackType = store.get('playbackType', PlaybackType.WEB) as PlaybackType; const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
const shouldDisableMediaFeatures = const shouldDisableMediaFeatures =
!isWindows() || !enableWindowsMediaSession || playbackType !== PlaybackType.WEB; !isWindows() || !enableWindowsMediaSession || playbackType !== PlayerType.WEB;
if (shouldDisableMediaFeatures) { if (shouldDisableMediaFeatures) {
app.commandLine.appendSwitch( app.commandLine.appendSwitch(
'disable-features', 'disable-features',
+1 -1
View File
@@ -96,7 +96,7 @@ export const RemoteContainer = () => {
}} }}
variant="transparent" variant="transparent"
/> />
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && ( {(song?._serverType === 'navidrome' || song?._serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}> <div style={{ margin: 'auto' }}>
<Tooltip label="Double click to clear" openDelay={1000}> <Tooltip label="Double click to clear" openDelay={1000}>
<Rating <Rating
+11 -120
View File
@@ -2,62 +2,41 @@ import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-mod
import { ModuleRegistry } from '@ag-grid-community/core'; import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import '@mantine/notifications/styles.css';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '/@/shared/styles/global.css'; import '/@/shared/styles/global.css';
import '@ag-grid-community/styles/ag-grid.css'; import '@ag-grid-community/styles/ag-grid.css';
import 'overlayscrollbars/overlayscrollbars.css'; import 'overlayscrollbars/overlayscrollbars.css';
import '/styles/overlayscrollbars.css';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import { PlayQueueHandlerContext } from '/@/renderer/features/player/context/play-queue-handler-context'; import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { useServerVersion } from '/@/renderer/hooks/use-server-version'; import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog'; import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog';
import { AppRouter } from '/@/renderer/router/app-router'; import { AppRouter } from '/@/renderer/router/app-router';
import { import { useCssSettings, useHotkeySettings, useSettingsStore } from '/@/renderer/store';
PlayerState,
useCssSettings,
useHotkeySettings,
usePlaybackSettings,
usePlayerStore,
useQueueControls,
useRemoteSettings,
useSettingsStore,
} from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme'; import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize'; import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data'; import '/styles/overlayscrollbars.css';
import { toast } from '/@/shared/components/toast/toast'; import { WebAudio } from '/@/shared/types/types';
import { PlaybackType, PlayerStatus, WebAudio } from '/@/shared/types/types';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => { export const App = () => {
const { mode, theme } = useAppTheme(); const { mode, theme } = useAppTheme();
const language = useSettingsStore((store) => store.general.language); const language = useSettingsStore((store) => store.general.language);
const { content, enabled } = useCssSettings(); const { content, enabled } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings();
const cssRef = useRef<HTMLStyleElement | null>(null); const cssRef = useRef<HTMLStyleElement | null>(null);
useDiscordRpc(); useDiscordRpc();
useServerVersion(); useServerVersion();
@@ -84,104 +63,16 @@ export const App = () => {
return () => {}; return () => {};
}, [content, enabled]); }, [content, enabled]);
const providerValue = useMemo(() => {
return { handlePlayQueueAdd };
}, [handlePlayQueueAdd]);
const webAudioProvider = useMemo(() => { const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio }; return { setWebAudio, webAudio };
}, [webAudio]); }, [webAudio]);
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
if (playbackType === PlaybackType.LOCAL) {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
mpvPlayer?.stop();
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties: Record<string, any> = {
speed: usePlayerStore.getState().speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
await mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
}
utils?.restoreQueue();
};
if (isElectron()) {
initializeMpv();
}
return () => {
clearQueue();
mpvPlayer?.stop();
mpvPlayer?.cleanup();
};
}, [clearQueue, playbackType]);
useEffect(() => { useEffect(() => {
if (isElectron()) { if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings); ipc?.send('set-global-shortcuts', bindings);
} }
}, [bindings]); }, [bindings]);
useEffect(() => {
if (utils) {
utils.onSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
...current,
status: PlayerStatus.PAUSED,
},
queue,
};
utils.saveQueue(stateToSave);
});
utils.onRestoreQueue((_event: any, data) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
setQueue(playerData, true);
}
updateSong(playerData.current.song);
});
}
return () => {
ipc?.removeAllListeners('renderer-restore-queue');
ipc?.removeAllListeners('renderer-save-queue');
};
}, [playbackType, restoreQueue]);
useEffect(() => {
if (remote) {
remote
?.updateSetting(
remoteSettings.enabled,
remoteSettings.port,
remoteSettings.username,
remoteSettings.password,
)
.catch((error) => {
toast.warn({ message: error, title: 'Failed to enable remote' });
});
}
// We only want to fire this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (language) { if (language) {
i18n.changeLanguage(language); i18n.changeLanguage(language);
@@ -200,11 +91,11 @@ export const App = () => {
}} }}
zIndex={50000} zIndex={50000}
/> />
<PlayQueueHandlerContext.Provider value={providerValue}> <WebAudioContext.Provider value={webAudioProvider}>
<WebAudioContext.Provider value={webAudioProvider}> <PlayerProvider>
<AppRouter /> <AppRouter />
</WebAudioContext.Provider> </PlayerProvider>
</PlayQueueHandlerContext.Provider> </WebAudioContext.Provider>
<IsUpdatedDialog /> <IsUpdatedDialog />
</MantineProvider> </MantineProvider>
); );
@@ -59,7 +59,6 @@ export const PosterCard = ({
<Image className={styles.image} src={data?.imageUrl} /> <Image className={styles.image} src={data?.imageUrl} />
<GridCardControls <GridCardControls
handleFavorite={controls.handleFavorite} handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
isHovered={isHovered} isHovered={isHovered}
itemData={data} itemData={data}
itemType={controls.itemType} itemType={controls.itemType}
@@ -9,16 +9,16 @@ import {
PLAYLIST_CONTEXT_MENU_ITEMS, PLAYLIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items'; } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGridContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { useHandleGridContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { Play, PlayQueueAddOptions } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
export const GridCardControls = ({ export const GridCardControls = ({
handleFavorite, handleFavorite,
handlePlayQueueAdd,
isHovered, isHovered,
itemData, itemData,
itemType, itemType,
@@ -30,7 +30,6 @@ export const GridCardControls = ({
itemType: LibraryItem; itemType: LibraryItem;
serverId: string; serverId: string;
}) => void; }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
isHovered?: boolean; isHovered?: boolean;
itemData: any; itemData: any;
itemType: LibraryItem; itemType: LibraryItem;
@@ -39,17 +38,18 @@ export const GridCardControls = ({
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite); const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const player = usePlayerContext();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => { const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handlePlayQueueAdd?.({ player.addToQueueByFetch(
byItemType: { itemData._serverId,
id: [itemData.id], [itemData.id],
type: itemType, itemType,
}, playType || playButtonBehavior,
playType: playType || playButtonBehavior, );
});
}; };
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => { const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
@@ -3,17 +3,17 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { RowClassRules, RowNode } from '@ag-grid-community/core'; import { RowClassRules, RowNode } from '@ag-grid-community/core';
import { MutableRefObject, useEffect, useMemo, useRef } from 'react'; import { MutableRefObject, useEffect, useMemo, useRef } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store'; import { usePlayerSong } from '/@/renderer/store';
import { Song } from '/@/shared/types/domain-types'; import { Song } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
interface UseCurrentSongRowStylesProps { interface UseCurrentSongRowStylesProps {
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesProps) => { export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesProps) => {
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused); const isFocusedRef = useRef<boolean>(isFocused);
@@ -50,11 +50,12 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
}; };
}, [currentSong?.albumId, currentSong?.id]); }, [currentSong?.albumId, currentSong?.id]);
useEffect(() => { usePlayerEvents(
// Redraw song rows when current song changes {
const unsubSongChange = usePlayerStore.subscribe( onCurrentSongChange: (properties, prev) => {
(state) => state.current.song, const song = properties.song;
(song, previousSong) => { const previousSong = prev.song;
if (tableRef?.current) { if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {}; const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) { if (api == null || columnApi == null) {
@@ -74,14 +75,8 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
api.redrawRows({ rowNodes }); api.redrawRows({ rowNodes });
} }
}, },
{ equalityFn: (a, b) => a?.id === b?.id }, onPlayerStatus: () => {
); const song = currentSong;
// Redraw song rows when the status changes
const unsubStatusChange = usePlayerStore.subscribe(
(state) => [state.current.status, state.current.song],
(current: (PlayerStatus | Song | undefined)[]) => {
const song = current[1] as Song;
if (tableRef?.current) { if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {}; const { api, columnApi } = tableRef?.current || {};
@@ -95,16 +90,9 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
api.redrawRows({ rowNodes }); api.redrawRows({ rowNodes });
} }
}, },
{ },
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus), [tableRef],
}, );
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [tableRef]);
return { return {
rowClassRules, rowClassRules,
@@ -36,7 +36,7 @@ import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-
import { useAppFocus, useContainerQuery } from '/@/renderer/hooks'; import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store'; import { useCurrentServer, usePlayerSong, usePlayerStatus } from '/@/renderer/store';
import { import {
PersistedTableColumn, PersistedTableColumn,
useGeneralSettings, useGeneralSettings,
@@ -85,9 +85,9 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const tableConfig = useTableSettings('albumDetail'); const tableConfig = useTableSettings('albumDetail');
const { setTable } = useSettingsStoreActions(); const { setTable } = useSettingsStoreActions();
const status = useCurrentStatus(); const status = usePlayerStatus();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const { externalLinks, lastFM, musicBrainz } = useGeneralSettings(); const { externalLinks, lastFM, musicBrainz } = useGeneralSettings();
const genreRoute = useGenreRoute(); const genreRoute = useGenreRoute();
@@ -290,7 +290,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
if (detailQuery.data.userFavorite) { if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({ deleteFavoriteMutation.mutate({
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { query: {
id: [detailQuery.data.id], id: [detailQuery.data.id],
type: LibraryItem.ALBUM, type: LibraryItem.ALBUM,
@@ -298,7 +298,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
}); });
} else { } else {
createFavoriteMutation.mutate({ createFavoriteMutation.mutate({
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { query: {
id: [detailQuery.data.id], id: [detailQuery.data.id],
type: LibraryItem.ALBUM, type: LibraryItem.ALBUM,
@@ -41,8 +41,8 @@ export const AlbumDetailHeader = forwardRef(
const { t } = useTranslation(); const { t } = useTranslation();
const showRating = const showRating =
detailQuery?.data?.serverType === ServerType.NAVIDROME || detailQuery?.data?._serverType === ServerType.NAVIDROME ||
detailQuery?.data?.serverType === ServerType.SUBSONIC; detailQuery?.data?._serverType === ServerType.SUBSONIC;
const originalDifferentFromRelease = const originalDifferentFromRelease =
detailQuery.data?.originalDate && detailQuery.data?.originalDate &&
@@ -137,7 +137,7 @@ export const AlbumDetailHeader = forwardRef(
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
updateRatingMutation.mutate({ updateRatingMutation.mutate({
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { query: {
item: [detailQuery.data], item: [detailQuery.data],
rating, rating,
@@ -69,7 +69,7 @@ const DummyAlbumDetailRoute = () => {
try { try {
if (wasFavorite) { if (wasFavorite) {
await deleteFavoriteMutation.mutateAsync({ await deleteFavoriteMutation.mutateAsync({
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { query: {
id: [detailQuery.data.id], id: [detailQuery.data.id],
type: LibraryItem.SONG, type: LibraryItem.SONG,
@@ -77,7 +77,7 @@ const DummyAlbumDetailRoute = () => {
}); });
} else { } else {
await createFavoriteMutation.mutateAsync({ await createFavoriteMutation.mutateAsync({
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { query: {
id: [detailQuery.data.id], id: [detailQuery.data.id],
type: LibraryItem.SONG, type: LibraryItem.SONG,
@@ -79,7 +79,7 @@ export const AlbumArtistDetailHeader = forwardRef(
}); });
}; };
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME; const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
return ( return (
<LibraryHeader <LibraryHeader
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,7 @@ export const useDiscordRpc = () => {
// Handle change detection // Handle change detection
const song = current[0]; const song = current[0];
const trackChanged = lastUniqueId !== song.uniqueId; const trackChanged = lastUniqueId !== song._uniqueId;
/* /*
1. If the song has just started, update status 1. If the song has just started, update status
@@ -51,7 +51,7 @@ export const useDiscordRpc = () => {
current[2] !== previous[2] current[2] !== previous[2]
) { ) {
if (trackChanged) { if (trackChanged) {
setlastUniqueId(song.uniqueId); setlastUniqueId(song._uniqueId);
} }
const start = Math.round(Date.now() - current[1] * 1000); const start = Math.round(Date.now() - current[1] * 1000);
@@ -119,12 +119,12 @@ export const useDiscordRpc = () => {
} }
if (discordSettings.showServerImage && song) { if (discordSettings.showServerImage && song) {
if (song.serverType === ServerType.JELLYFIN && song.imageUrl) { if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
activity.largeImageKey = song.imageUrl; activity.largeImageKey = song.imageUrl;
} else if (song.serverType === ServerType.NAVIDROME) { } else if (song._serverType === ServerType.NAVIDROME) {
try { try {
const info = await controller.getAlbumInfo({ const info = await controller.getAlbumInfo({
apiClientProps: { serverId: song.serverId }, apiClientProps: { serverId: song._serverId },
query: { id: song.albumId }, query: { id: song.albumId },
}); });
@@ -99,7 +99,7 @@ export const lyricsQueries = {
return queryOptions({ return queryOptions({
gcTime: Infinity, gcTime: Infinity,
queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => { queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => {
const server = getServerById(song?.serverId); const server = getServerById(song?._serverId);
if (!server) throw new Error('Server not found'); if (!server) throw new Error('Server not found');
if (!song) return null; if (!song) return null;
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form'; import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
import { import {
useCurrentSong, usePlayerSong,
useLyricsSettings, useLyricsSettings,
useSettingsStore, useSettingsStore,
useSettingsStoreActions, useSettingsStoreActions,
@@ -38,7 +38,7 @@ export const LyricsActions = ({
setIndex, setIndex,
}: LyricsActionsProps) => { }: LyricsActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings(); const { delayMs, sources } = useLyricsSettings();
+8 -8
View File
@@ -20,7 +20,7 @@ import {
UnsynchronizedLyricsProps, UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics'; } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { queryClient } from '/@/renderer/lib/react-query'; import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentSong, useLyricsSettings, usePlayerStore } from '/@/renderer/store'; import { usePlayerSong, useLyricsSettings, usePlayerStore } from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
@@ -29,7 +29,7 @@ import { Text } from '/@/shared/components/text/text';
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/shared/types/domain-types'; import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/shared/types/domain-types';
export const Lyrics = () => { export const Lyrics = () => {
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const { const {
enableAutoTranslation, enableAutoTranslation,
translationApiKey, translationApiKey,
@@ -45,7 +45,7 @@ export const Lyrics = () => {
lyricsQueries.songLyrics( lyricsQueries.songLyrics(
{ {
query: { songId: currentSong?.id || '' }, query: { songId: currentSong?.id || '' },
serverId: currentSong?.serverId || '', serverId: currentSong?._serverId || '',
}, },
currentSong, currentSong,
), ),
@@ -73,13 +73,13 @@ export const Lyrics = () => {
const handleOnResetLyric = useCallback(() => { const handleOnResetLyric = useCallback(() => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
exact: true, exact: true,
queryKey: queryKeys.songs.lyrics(currentSong?.serverId, { songId: currentSong?.id }), queryKey: queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }),
}); });
}, [currentSong?.id, currentSong?.serverId]); }, [currentSong?.id, currentSong?._serverId]);
const handleOnRemoveLyric = useCallback(() => { const handleOnRemoveLyric = useCallback(() => {
queryClient.setQueryData( queryClient.setQueryData(
queryKeys.songs.lyrics(currentSong?.serverId, { songId: currentSong?.id }), queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }),
(prev: FullLyricsMetadata | undefined) => { (prev: FullLyricsMetadata | undefined) => {
if (!prev) { if (!prev) {
return undefined; return undefined;
@@ -91,7 +91,7 @@ export const Lyrics = () => {
}; };
}, },
); );
}, [currentSong?.id, currentSong?.serverId]); }, [currentSong?.id, currentSong?._serverId]);
const fetchTranslation = useCallback(async () => { const fetchTranslation = useCallback(async () => {
if (!lyrics) return; if (!lyrics) return;
@@ -126,7 +126,7 @@ export const Lyrics = () => {
remoteSource: override?.source as LyricSource | undefined, remoteSource: override?.source as LyricSource | undefined,
song: currentSong, song: currentSong,
}, },
serverId: currentSong?.serverId || '', serverId: currentSong?._serverId || '',
}), }),
); );
@@ -8,17 +8,16 @@ import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { import {
useCurrentPlayer,
useCurrentStatus,
useCurrentTime,
useLyricsSettings, useLyricsSettings,
usePlaybackType, usePlaybackType,
usePlayerActions,
usePlayerData, usePlayerData,
useSeeked, usePlayerNum,
useSetCurrentTime, usePlayerStatus,
usePlayerTimestamp,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
import { PlaybackType, PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const utils = isElectron() ? window.api.utils : null; const utils = isElectron() ? window.api.utils : null;
@@ -38,33 +37,33 @@ export const SynchronizedLyrics = ({
translatedLyrics, translatedLyrics,
}: SynchronizedLyricsProps) => { }: SynchronizedLyricsProps) => {
const playersRef = PlayersRef; const playersRef = PlayersRef;
const status = useCurrentStatus(); const status = usePlayerStatus();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const playerData = usePlayerData(); const playerData = usePlayerData();
const now = useCurrentTime(); const now = usePlayerTimestamp();
const settings = useLyricsSettings(); const settings = useLyricsSettings();
const currentPlayer = useCurrentPlayer(); const currentPlayer = usePlayerNum();
const currentPlayerRef = const currentPlayerRef =
currentPlayer === 1 ? playersRef.current?.player1 : playersRef.current?.player2; currentPlayer === 1 ? playersRef.current?.player1 : playersRef.current?.player2;
const setCurrentTime = useSetCurrentTime();
const { handleScrobbleFromSeek } = useScrobble(); const { handleScrobbleFromSeek } = useScrobble();
const handleSeek = useCallback( const handleSeek = useCallback(
(time: number) => { (time: number) => {
if (playbackType === PlaybackType.LOCAL && mpvPlayer) { if (playbackType === PlayerType.LOCAL && mpvPlayer) {
mpvPlayer.seekTo(time); mpvPlayer.seekTo(time);
setCurrentTime(time, true); // setCurrentTime(time, true);
} else { } else {
setCurrentTime(time, true); // setCurrentTime(time, true);
handleScrobbleFromSeek(time); handleScrobbleFromSeek(time);
mpris?.updateSeek(time); mpris?.updateSeek(time);
currentPlayerRef?.seekTo(time); currentPlayerRef?.seekTo(time);
} }
}, },
[currentPlayerRef, handleScrobbleFromSeek, playbackType, setCurrentTime], [currentPlayerRef, handleScrobbleFromSeek, playbackType],
); );
const seeked = useSeeked(); // const seeked = useSeeked();
// A reference to the timeout handler // A reference to the timeout handler
const lyricTimer = useRef<null | ReturnType<typeof setTimeout>>(null); const lyricTimer = useRef<null | ReturnType<typeof setTimeout>>(null);
@@ -97,7 +96,7 @@ export const SynchronizedLyrics = ({
}; };
const getCurrentTime = useCallback(async () => { const getCurrentTime = useCallback(async () => {
if (isElectron() && playbackType !== PlaybackType.WEB) { if (isElectron() && playbackType !== PlayerType.WEB) {
if (mpvPlayer) { if (mpvPlayer) {
return mpvPlayer.getCurrentTime(); return mpvPlayer.getCurrentTime();
} }
@@ -109,7 +108,7 @@ export const SynchronizedLyrics = ({
} }
const player = const player =
playerData.current.player === 1 playerData.player.playerNum === 1
? playersRef.current.player1 ? playersRef.current.player1
: playersRef.current.player2; : playersRef.current.player2;
const underlying = player?.getInternalPlayer(); const underlying = player?.getInternalPlayer();
@@ -282,16 +281,16 @@ export const SynchronizedLyrics = ({
// we may be playing the same track (repeat one). In this case, we also // we may be playing the same track (repeat one). In this case, we also
// need to restart playback // need to restart playback
const restarted = status === PlayerStatus.PLAYING && now === 0; const restarted = status === PlayerStatus.PLAYING && now === 0;
if (!seeked && !restarted) { // if (!seeked && !restarted) {
return; // return;
} // }
if (lyricTimer.current) { if (lyricTimer.current) {
clearTimeout(lyricTimer.current); clearTimeout(lyricTimer.current);
} }
setCurrentLyric(now * 1000 - delayMsRef.current); setCurrentLyric(now * 1000 - delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]); }, [now, setCurrentLyric, status]);
useEffect(() => { useEffect(() => {
// Guaranteed cleanup; stop the timer, and just in case also increment // Guaranteed cleanup; stop the timer, and just in case also increment
@@ -7,15 +7,12 @@ import { useTranslation } from 'react-i18next';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table'; import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
import { usePlayerStore, useSetCurrentTime } from '/@/renderer/store/player.store';
import { usePlaybackType } from '/@/renderer/store/settings.store'; import { usePlaybackType } from '/@/renderer/store/settings.store';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Popover } from '/@/shared/components/popover/popover'; import { Popover } from '/@/shared/components/popover/popover';
import { Song } from '/@/shared/types/domain-types'; import { Song } from '/@/shared/types/domain-types';
import { PlaybackType, TableType } from '/@/shared/types/types'; import { TableType } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
@@ -33,30 +30,30 @@ export const PlayQueueListControls = ({
type, type,
}: PlayQueueListOptionsProps) => { }: PlayQueueListOptionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { // const {
clearQueue, // clearQueue,
moveToBottomOfQueue, // moveToBottomOfQueue,
moveToNextOfQueue, // moveToNextOfQueue,
moveToTopOfQueue, // moveToTopOfQueue,
removeFromQueue, // removeFromQueue,
shuffleQueue, // shuffleQueue,
} = useQueueControls(); // } = useQueueControls();
const { pause } = usePlayerControls(); // const { pause } = usePlayerControls();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const setCurrentTime = useSetCurrentTime(); // const setCurrentTime = useSetCurrentTime();
const handleMoveToNext = () => { const handleMoveToNext = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows(); const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId); const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; if (!uniqueIds?.length) return;
const playerData = moveToNextOfQueue(uniqueIds); // const playerData = moveToNextOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
}; };
const handleMoveToBottom = () => { const handleMoveToBottom = () => {
@@ -64,11 +61,11 @@ export const PlayQueueListControls = ({
const uniqueIds = selectedRows?.map((row) => row.uniqueId); const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; if (!uniqueIds?.length) return;
const playerData = moveToBottomOfQueue(uniqueIds); // const playerData = moveToBottomOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
}; };
const handleMoveToTop = () => { const handleMoveToTop = () => {
@@ -76,11 +73,11 @@ export const PlayQueueListControls = ({
const uniqueIds = selectedRows?.map((row) => row.uniqueId); const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; if (!uniqueIds?.length) return;
const playerData = moveToTopOfQueue(uniqueIds); // const playerData = moveToTopOfQueue(uniqueIds);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
}; };
const handleRemoveSelected = () => { const handleRemoveSelected = () => {
@@ -88,43 +85,42 @@ export const PlayQueueListControls = ({
const uniqueIds = selectedRows?.map((row) => row.uniqueId); const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; if (!uniqueIds?.length) return;
const currentSong = usePlayerStore.getState().current.song; // const currentSong = usePlayerStore.getState().current.song;
const playerData = removeFromQueue(uniqueIds); // const playerData = removeFromQueue(uniqueIds);
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId); // const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) { // if (isCurrentSongRemoved) {
setQueue(playerData); // setQueue(playerData);
} else { // } else {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
} // }
if (isCurrentSongRemoved) { // if (isCurrentSongRemoved) {
updateSong(playerData.current.song); // updateSong(playerData.current.song);
} // }
}; };
const handleClearQueue = () => { const handleClearQueue = () => {
const playerData = clearQueue(); // const playerData = clearQueue();
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
setQueue(playerData); // setQueue(playerData);
mpvPlayer!.pause(); // mpvPlayer!.pause();
} // }
updateSong(undefined); updateSong(undefined);
setCurrentTime(0); // setCurrentTime(0);
pause(); // pause();
}; };
const handleShuffleQueue = () => { const handleShuffleQueue = () => {
const playerData = shuffleQueue(); // const playerData = shuffleQueue();
// if (playbackType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) { // setQueueNext(playerData);
setQueueNext(playerData); // }
}
}; };
const handleSearchTerm = useCallback( const handleSearchTerm = useCallback(
@@ -1,15 +1,9 @@
import type { import type { RowClassRules, RowDragEvent, RowNode } from '@ag-grid-community/core';
CellDoubleClickedEvent,
RowClassRules,
RowDragEvent,
RowNode,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import type { Ref } from 'react'; import type { Ref } from 'react';
import { useMergedRef } from '@mantine/hooks';
import '@ag-grid-community/styles/ag-theme-alpine.css'; import '@ag-grid-community/styles/ag-theme-alpine.css';
import isElectron from 'is-electron'; import { useMergedRef } from '@mantine/hooks';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
@@ -19,32 +13,22 @@ import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-tabl
import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback'; import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { import {
useAppStoreActions, useAppStoreActions,
useCurrentSong, usePlayerQueue,
useCurrentStatus, usePlayerSong,
useDefaultQueue, usePlayerStatus,
usePlayerControls,
usePreviousSong,
useQueueControls,
useVolume,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { import {
PersistedTableColumn, PersistedTableColumn,
usePlaybackType,
useSettingsStore, useSettingsStore,
useSettingsStoreActions, useSettingsStoreActions,
useTableSettings, useTableSettings,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { searchSongs } from '/@/renderer/utils/search-songs'; import { searchSongs } from '/@/renderer/utils/search-songs';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlaybackType, TableType } from '/@/shared/types/types'; import { TableType } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
type QueueProps = { type QueueProps = {
searchTerm?: string; searchTerm?: string;
@@ -54,18 +38,13 @@ type QueueProps = {
export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => { export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef); const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue(); const queue = usePlayerQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls(); const currentSong = usePlayerSong();
const currentSong = useCurrentSong(); const status = usePlayerStatus();
const previousSong = usePreviousSong();
const status = useCurrentStatus();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions(); const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type); const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>(); const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playbackType = usePlaybackType();
const { play } = usePlayerControls();
const volume = useVolume();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused); const isFocusedRef = useRef<boolean>(isFocused);
@@ -94,26 +73,26 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
[tableConfig.columns], [tableConfig.columns],
); );
const handleDoubleClick = (e: CellDoubleClickedEvent) => { // const handleDoubleClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId); // const playerData = setCurrentTrack(e.data.uniqueId);
updateSong(playerData.current.song); // updateSong(playerData.current.song);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume); // mpvPlayer!.volume(volume);
setQueue(playerData, false); // setQueue(playerData, false);
} else { // } else {
const player = // const player =
playerData.current.player === 1 // playerData.current.player === 1
? PlayersRef.current?.player1 // ? PlayersRef.current?.player1
: PlayersRef.current?.player2; // : PlayersRef.current?.player2;
const underlying = player?.getInternalPlayer(); // const underlying = player?.getInternalPlayer();
if (underlying) { // if (underlying) {
underlying.currentTime = 0; // underlying.currentTime = 0;
} // }
} // }
play(); // play();
}; // };
const handleDragStart = () => { const handleDragStart = () => {
if (type === 'sideDrawerQueue') { if (type === 'sideDrawerQueue') {
@@ -125,14 +104,14 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const handleDragEnd = (e: RowDragEvent<QueueSong>) => { const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return; if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes const selectedUniqueIds = e.nodes
.map((node) => node.data?.uniqueId) .map((node) => node.data?._uniqueId)
.filter((e) => e !== undefined); .filter((e) => e !== undefined);
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId); // const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
if (type === 'sideDrawerQueue') { if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false }); setAppStore({ isReorderingQueue: false });
@@ -146,8 +125,8 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const handleGridReady = () => { const handleGridReady = () => {
const { api } = tableRef?.current || {}; const { api } = tableRef?.current || {};
if (currentSong?.uniqueId) { if (currentSong?._uniqueId) {
const currentNode = api?.getRowNode(currentSong?.uniqueId); const currentNode = api?.getRowNode(currentSong?._uniqueId);
if (!currentNode) return; if (!currentNode) return;
api?.ensureNodeVisible(currentNode, 'middle'); api?.ensureNodeVisible(currentNode, 'middle');
@@ -199,10 +178,18 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const rowClassRules = useMemo<RowClassRules | undefined>(() => { const rowClassRules = useMemo<RowClassRules | undefined>(() => {
return { return {
'current-song': (params) => { 'current-song': (params) => {
return params.data.uniqueId === currentSong?.uniqueId; return params.data.uniqueId === currentSong?._uniqueId;
}, },
}; };
}, [currentSong?.uniqueId]); }, [currentSong?._uniqueId]);
const previousSongRef = useRef<QueueSong | undefined>(undefined);
useEffect(() => {
if (currentSong) {
previousSongRef.current = currentSong;
}
}, [currentSong]);
// Redraw the current song row when the previous song changes // Redraw the current song row when the previous song changes
useEffect(() => { useEffect(() => {
@@ -212,11 +199,11 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
return; return;
} }
const currentNode = currentSong?.uniqueId const currentNode = currentSong?._uniqueId
? api.getRowNode(currentSong.uniqueId) ? api.getRowNode(currentSong._uniqueId)
: undefined; : undefined;
const previousNode = previousSong?.uniqueId const previousNode = previousSongRef.current?._uniqueId
? api.getRowNode(previousSong?.uniqueId) ? api.getRowNode(previousSongRef.current?._uniqueId)
: undefined; : undefined;
const rowNodes = [currentNode, previousNode].filter( const rowNodes = [currentNode, previousNode].filter(
@@ -231,7 +218,7 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
} }
} }
} }
}, [currentSong, previousSong, tableConfig.followCurrentSong, status]); }, [currentSong, previousSongRef, tableConfig.followCurrentSong, status]);
// As a separate rule, update the current row when focus changes. This is // As a separate rule, update the current row when focus changes. This is
// to prevent queue scrolling when the application loses and then gains focus. // to prevent queue scrolling when the application loses and then gains focus.
@@ -243,8 +230,8 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
return; return;
} }
const currentNode = currentSong?.uniqueId const currentNode = currentSong?._uniqueId
? api.getRowNode(currentSong.uniqueId) ? api.getRowNode(currentSong._uniqueId)
: undefined; : undefined;
if (currentNode) { if (currentNode) {
@@ -266,7 +253,7 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
columnDefs={columnDefs} columnDefs={columnDefs}
context={{ context={{
currentSong, currentSong,
handleDoubleClick, // handleDoubleClick,
isFocused, isFocused,
isQueue: true, isQueue: true,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
@@ -276,7 +263,7 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
deselectOnClickOutside={type === 'fullScreen'} deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
onCellContextMenu={onCellContextMenu} onCellContextMenu={onCellContextMenu}
onCellDoubleClicked={handleDoubleClick} // onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange} onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart} onDragStarted={handleDragStart}
@@ -0,0 +1,259 @@
import type { RefObject } from 'react';
import isElectron from 'is-electron';
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { useSettingsStore } from '/@/renderer/store';
import { PlayerStatus } from '/@/shared/types/types';
export interface MpvPlayerEngineHandle extends AudioPlayer {}
interface MpvPlayerEngineProps {
currentSrc: string | undefined;
isMuted: boolean;
isTransitioning: boolean;
nextSrc: string | undefined;
onEnded: () => void;
onProgress: (e: PlayerOnProgressProps) => void;
playerRef: RefObject<MpvPlayerEngineHandle>;
playerStatus: PlayerStatus;
speed?: number;
volume: number;
}
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
const PROGRESS_UPDATE_INTERVAL = 250;
const TRANSITION_PROGRESS_INTERVAL = 10;
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const {
currentSrc,
isMuted,
isTransitioning,
nextSrc,
onEnded,
onProgress,
playerRef,
playerStatus,
speed,
volume,
} = props;
const [internalVolume, setInternalVolume] = useState(volume / 100 || 0);
const [duration] = useState(0);
const [previousCurrentSrc, setPreviousCurrentSrc] = useState<string | undefined>(currentSrc);
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties);
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
mpvPlayer?.stop();
if (!isRunning) {
const properties: Record<string, any> = {
// speed: usePlayerStore.getState().speed,
...getMpvProperties(mpvProperties),
};
await mpvPlayer?.initialize({
extraParameters: mpvExtraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
utils?.restoreQueue();
};
initializeMpv();
return () => {
mpvPlayer?.stop();
mpvPlayer?.cleanup();
};
}, [mpvExtraParameters, mpvProperties]);
// Update volume
useEffect(() => {
if (!mpvPlayer) {
return;
}
const vol = volume / 100 || 0;
setInternalVolume(vol);
mpvPlayer.volume(volume);
}, [volume]);
// Update mute status
useEffect(() => {
if (!mpvPlayer) {
return;
}
mpvPlayer.mute(isMuted);
}, [isMuted]);
// Update speed/playback rate
useEffect(() => {
if (!mpvPlayer) {
return;
}
if (!speed) {
return;
}
mpvPlayer.setProperties({ speed });
}, [speed]);
// Handle current song changes - update queue position 0
// When currentSrc changes, we need to update the queue
useEffect(() => {
if (!mpvPlayer) {
return;
}
// If currentSrc changed, update the queue
if (currentSrc !== previousCurrentSrc) {
if (currentSrc) {
// Set current song at position 0 and next song at position 1
mpvPlayer.setQueue(currentSrc, nextSrc, playerStatus !== PlayerStatus.PLAYING);
setPreviousCurrentSrc(currentSrc);
} else {
// Clear queue if no current song
mpvPlayer.setQueue(undefined, undefined, true);
setPreviousCurrentSrc(undefined);
}
} else {
// If currentSrc hasn't changed but nextSrc has, update position 1
// This happens when the next song changes but current song stays the same
if (currentSrc) {
mpvPlayer.setQueueNext(nextSrc);
}
}
}, [currentSrc, previousCurrentSrc, nextSrc, playerStatus]);
// Handle play/pause status
useEffect(() => {
if (!mpvPlayer) {
return;
}
if (playerStatus === PlayerStatus.PLAYING) {
mpvPlayer.play();
} else if (playerStatus === PlayerStatus.PAUSED) {
mpvPlayer.pause();
}
}, [playerStatus]);
// Set up progress tracking
useEffect(() => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
const updateProgress = async () => {
if (!mpvPlayer) {
return;
}
try {
const time = await mpvPlayer.getCurrentTime();
if (time !== undefined) {
onProgress({
played: time / (duration || time + 10),
playedSeconds: time,
});
}
} catch {
// Handle error silently
}
};
if (currentSrc) {
const interval = isTransitioning
? TRANSITION_PROGRESS_INTERVAL
: PROGRESS_UPDATE_INTERVAL;
progressIntervalRef.current = setInterval(updateProgress, interval);
updateProgress();
}
return () => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
};
}, [currentSrc, isTransitioning, duration, onProgress]);
useEffect(() => {
if (!mpvPlayerListener) {
return;
}
const handleOnEnded = () => {
onEnded();
};
mpvPlayerListener.rendererAutoNext(handleOnEnded);
return () => {
ipc?.removeAllListeners('renderer-player-auto-next');
};
}, [nextSrc, onEnded]);
useImperativeHandle<MpvPlayerEngineHandle, MpvPlayerEngineHandle>(playerRef, () => ({
decreaseVolume(by: number) {
const newVol = Math.max(0, internalVolume - by / 100);
setInternalVolume(newVol);
if (mpvPlayer) {
mpvPlayer.volume(newVol * 100);
}
},
increaseVolume(by: number) {
const newVol = Math.min(1, internalVolume + by / 100);
setInternalVolume(newVol);
if (mpvPlayer) {
mpvPlayer.volume(newVol * 100);
}
},
pause() {
if (mpvPlayer) {
mpvPlayer.pause();
}
},
play() {
if (mpvPlayer) {
mpvPlayer.play();
}
},
seekTo(seekTo: number) {
if (mpvPlayer) {
mpvPlayer.seekTo(seekTo);
}
},
setVolume(vol: number) {
const volDecimal = vol / 100 || 0;
setInternalVolume(volDecimal);
if (mpvPlayer) {
mpvPlayer.volume(vol);
}
},
}));
return <div id="mpv-player-engine" style={{ display: 'none' }} />;
};
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
@@ -0,0 +1,166 @@
import type { RefObject } from 'react';
import { useImperativeHandle, useRef, useState } from 'react';
import ReactPlayer from 'react-player';
import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';
import { PlayerStatus } from '/@/shared/types/types';
export interface WebPlayerEngineHandle extends AudioPlayer {
player1(): {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
player2(): {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
}
interface WebPlayerEngineProps {
isMuted: boolean;
isTransitioning: boolean;
onEndedPlayer1: () => void;
onEndedPlayer2: () => void;
onProgressPlayer1: (e: PlayerOnProgressProps) => void;
onProgressPlayer2: (e: PlayerOnProgressProps) => void;
playerNum: number;
playerRef: RefObject<WebPlayerEngineHandle>;
playerStatus: PlayerStatus;
speed?: number;
src1: string | undefined;
src2: string | undefined;
volume: number;
}
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
// the first time. This workaround is important for Safari, which seems to require the
// source to be connected PRIOR to resuming audio context
const EMPTY_SOURCE =
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const {
isMuted,
isTransitioning,
onEndedPlayer1,
onEndedPlayer2,
onProgressPlayer1,
onProgressPlayer2,
playerNum,
playerRef,
playerStatus,
speed,
src1,
src2,
volume,
} = props;
const player1Ref = useRef<null | ReactPlayer>(null);
const player2Ref = useRef<null | ReactPlayer>(null);
const [internalVolume1, setInternalVolume1] = useState(volume / 100 || 0);
const [internalVolume2, setInternalVolume2] = useState(volume / 100 || 0);
useImperativeHandle<WebPlayerEngineHandle, WebPlayerEngineHandle>(playerRef, () => ({
decreaseVolume(by: number) {
setInternalVolume1(Math.max(0, internalVolume1 - by / 100));
setInternalVolume2(Math.max(0, internalVolume2 - by / 100));
},
increaseVolume(by: number) {
setInternalVolume1(Math.min(1, internalVolume1 + by / 100));
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
},
pause() {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
},
play() {
if (playerNum === 1) {
player1Ref.current?.getInternalPlayer()?.play();
} else {
player2Ref.current?.getInternalPlayer()?.play();
}
},
player1() {
return {
ref: player1Ref?.current,
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
};
},
player2() {
return {
ref: player2Ref?.current,
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
};
},
seekTo(seekTo: number) {
playerNum === 1
? player1Ref.current?.seekTo(seekTo)
: player2Ref.current?.seekTo(seekTo);
},
setVolume(volume: number) {
setInternalVolume1(volume / 100 || 0);
setInternalVolume2(volume / 100 || 0);
},
setVolume1(volume: number) {
setInternalVolume1(volume / 100 || 0);
},
setVolume2(volume: number) {
setInternalVolume2(volume / 100 || 0);
},
}));
const volume1 = convertToLogVolume(internalVolume1);
const volume2 = convertToLogVolume(internalVolume2);
return (
<div id="web-player-engine" style={{ display: 'none' }}>
{Boolean(src1) && (
<ReactPlayer
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
controls={false}
height={0}
id="web-player-1"
muted={isMuted}
onEnded={src1 ? () => onEndedPlayer1() : undefined}
onProgress={onProgressPlayer1}
playbackRate={speed || 1}
playing={playerNum === 1 && playerStatus === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
ref={player1Ref}
url={src1 || EMPTY_SOURCE}
volume={volume1}
width={0}
/>
)}
{Boolean(src2) && (
<ReactPlayer
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
controls={false}
height={0}
id="web-player-2"
muted={isMuted}
onEnded={src2 ? () => onEndedPlayer2() : undefined}
onProgress={onProgressPlayer2}
playbackRate={speed || 1}
playing={playerNum === 2 && playerStatus === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
ref={player2Ref}
url={src2 || EMPTY_SOURCE}
volume={volume2}
width={0}
/>
)}
</div>
);
};
WebPlayerEngine.displayName = 'WebPlayerEngine';
@@ -0,0 +1,147 @@
import { t } from 'i18next';
import isElectron from 'is-electron';
import { useCallback, useEffect } from 'react';
import { usePlayerActions } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;
const ipc = isElectron() ? window.api.ipc : null;
export const useMainPlayerListener = () => {
const {
decreaseVolume,
increaseVolume,
mediaAutoNext,
mediaNext,
mediaPause,
mediaPlay,
mediaPrevious,
mediaSkipBackward,
mediaSkipForward,
mediaStop,
mediaToggleMute,
mediaTogglePlayPause,
toggleRepeat,
toggleShuffle,
} = usePlayerActions();
const handleMpvError = useCallback(
(message: string) => {
toast.error({
id: 'mpv-error',
message,
title: t('error.playbackError', { postProcess: 'sentenceCase' }) as string,
});
mediaPause();
mpvPlayer!.pause();
},
[mediaPause],
);
useEffect(() => {
if (!mpvPlayerListener) {
return;
}
mpvPlayerListener.rendererPlayPause(() => {
mediaTogglePlayPause();
});
mpvPlayerListener.rendererNext(() => {
mediaNext();
});
mpvPlayerListener.rendererPrevious(() => {
mediaPrevious();
});
mpvPlayerListener.rendererPlayPause(() => {
mediaTogglePlayPause();
});
mpvPlayerListener.rendererPlay(() => {
mediaPlay();
});
mpvPlayerListener.rendererPause(() => {
mediaPause();
});
mpvPlayerListener.rendererStop(() => {
mediaStop();
});
mpvPlayerListener.rendererSkipForward(() => {
mediaSkipForward();
});
mpvPlayerListener.rendererSkipBackward(() => {
mediaSkipBackward();
});
mpvPlayerListener.rendererAutoNext(() => {
mediaAutoNext();
});
mpvPlayerListener.rendererToggleShuffle(() => {
toggleShuffle();
});
mpvPlayerListener.rendererToggleRepeat(() => {
toggleRepeat();
});
mpvPlayerListener.rendererVolumeMute(() => {
mediaToggleMute();
});
mpvPlayerListener.rendererVolumeUp(() => {
increaseVolume(1);
});
mpvPlayerListener.rendererVolumeDown(() => {
decreaseVolume(1);
});
mpvPlayerListener.rendererError((_event: any, message: string) => {
handleMpvError(message);
});
return () => {
ipc?.removeAllListeners('renderer-player-play-pause');
ipc?.removeAllListeners('renderer-player-next');
ipc?.removeAllListeners('renderer-player-previous');
ipc?.removeAllListeners('renderer-player-play-pause');
ipc?.removeAllListeners('renderer-player-play');
ipc?.removeAllListeners('renderer-player-pause');
ipc?.removeAllListeners('renderer-player-stop');
ipc?.removeAllListeners('renderer-player-skip-forward');
ipc?.removeAllListeners('renderer-player-skip-backward');
ipc?.removeAllListeners('renderer-player-auto-next');
ipc?.removeAllListeners('renderer-player-toggle-shuffle');
ipc?.removeAllListeners('renderer-player-toggle-repeat');
ipc?.removeAllListeners('renderer-player-volume-mute');
ipc?.removeAllListeners('renderer-player-volume-up');
ipc?.removeAllListeners('renderer-player-volume-down');
ipc?.removeAllListeners('renderer-player-error');
};
}, [
decreaseVolume,
handleMpvError,
increaseVolume,
mediaAutoNext,
mediaNext,
mediaPause,
mediaPlay,
mediaPrevious,
mediaSkipForward,
mediaSkipBackward,
mediaStop,
mediaToggleMute,
mediaTogglePlayPause,
toggleRepeat,
toggleShuffle,
]);
};
@@ -0,0 +1,124 @@
import { useEffect } from 'react';
import {
subscribeCurrentTrack,
subscribePlayerMute,
subscribePlayerProgress,
subscribePlayerQueue,
subscribePlayerRepeat,
subscribePlayerSeekToTimestamp,
subscribePlayerShuffle,
subscribePlayerSpeed,
subscribePlayerStatus,
subscribePlayerVolume,
} from '/@/renderer/store';
import { QueueData, QueueSong } from '/@/shared/types/domain-types';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
interface PlayerEvents {
cleanup: () => void;
}
interface PlayerEventsCallbacks {
onCurrentSongChange?: (
properties: { index: number; song: QueueSong | undefined },
prev: { index: number; song: QueueSong | undefined },
) => void;
onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void;
onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void;
onPlayerQueueChange?: (queue: QueueData, prev: QueueData) => void;
onPlayerRepeat?: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void;
onPlayerSeek?: (properties: { seconds: number }, prev: { seconds: number }) => void;
onPlayerSeekToTimestamp?: (
properties: { timestamp: number },
prev: { timestamp: number },
) => void;
onPlayerShuffle?: (
properties: { shuffle: PlayerShuffle },
prev: { shuffle: PlayerShuffle },
) => void;
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
}
export function usePlayerEvents(callbacks: PlayerEventsCallbacks, deps: React.DependencyList) {
useEffect(() => {
const engine = createPlayerEvents(callbacks);
return () => {
engine.cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps]);
}
function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
const unsubscribers: (() => void)[] = [];
// Subscribe to current track changes
if (callbacks.onCurrentSongChange) {
const unsubscribe = subscribeCurrentTrack(callbacks.onCurrentSongChange);
unsubscribers.push(unsubscribe);
}
// Subscribe to player progress
if (callbacks.onPlayerProgress) {
const unsubscribe = subscribePlayerProgress(callbacks.onPlayerProgress);
unsubscribers.push(unsubscribe);
}
// Subscribe to queue changes
if (callbacks.onPlayerQueueChange) {
const unsubscribe = subscribePlayerQueue(callbacks.onPlayerQueueChange);
unsubscribers.push(unsubscribe);
}
// Subscribe to seek events
if (callbacks.onPlayerSeekToTimestamp) {
const unsubscribe = subscribePlayerSeekToTimestamp(callbacks.onPlayerSeekToTimestamp);
unsubscribers.push(unsubscribe);
}
// Subscribe to player status changes
if (callbacks.onPlayerStatus) {
const unsubscribe = subscribePlayerStatus(callbacks.onPlayerStatus);
unsubscribers.push(unsubscribe);
}
// Subscribe to volume changes
if (callbacks.onPlayerVolume) {
const unsubscribe = subscribePlayerVolume(callbacks.onPlayerVolume);
unsubscribers.push(unsubscribe);
}
// Subscribe to mute changes
if (callbacks.onPlayerMute) {
const unsubscribe = subscribePlayerMute(callbacks.onPlayerMute);
unsubscribers.push(unsubscribe);
}
// Subscribe to speed changes
if (callbacks.onPlayerSpeed) {
const unsubscribe = subscribePlayerSpeed(callbacks.onPlayerSpeed);
unsubscribers.push(unsubscribe);
}
// Subscribe to repeat changes
if (callbacks.onPlayerRepeat) {
const unsubscribe = subscribePlayerRepeat(callbacks.onPlayerRepeat);
unsubscribers.push(unsubscribe);
}
// Subscribe to shuffle changes
if (callbacks.onPlayerShuffle) {
const unsubscribe = subscribePlayerShuffle(callbacks.onPlayerShuffle);
unsubscribers.push(unsubscribe);
}
return {
cleanup: () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
},
};
}
@@ -1,4 +1,4 @@
import type { Song } from '/@/shared/types/domain-types'; import type { QueueSong, Song } from '/@/shared/types/domain-types';
import type { CrossfadeStyle } from '/@/shared/types/types'; import type { CrossfadeStyle } from '/@/shared/types/types';
import type { ReactPlayerProps } from 'react-player'; import type { ReactPlayerProps } from 'react-player';
@@ -18,12 +18,17 @@ import { api } from '/@/renderer/api';
import { import {
crossfadeHandler, crossfadeHandler,
gaplessHandler, gaplessHandler,
} from '/@/renderer/components/audio-player/utils/list-handlers'; } from '/@/renderer/features/player/audio-player/utils/list-handlers';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store'; import {
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store'; TranscodingConfig,
usePlaybackSettings,
usePlayerSpeed,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { PlaybackStyle, PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus, PlayerStyle } from '/@/shared/types/types';
export type AudioPlayerProgress = { export type AudioPlayerProgress = {
loaded: number; loaded: number;
@@ -38,7 +43,7 @@ interface AudioPlayerProps extends ReactPlayerProps {
crossfadeStyle: CrossfadeStyle; crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2; currentPlayer: 1 | 2;
muted: boolean; muted: boolean;
playbackStyle: PlaybackStyle; playbackStyle: PlayerStyle;
player1?: Song; player1?: Song;
player2?: Song; player2?: Song;
status: PlayerStatus; status: PlayerStatus;
@@ -57,26 +62,30 @@ const getDuration = (ref: any) => {
const EMPTY_SOURCE = const EMPTY_SOURCE =
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV'; 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): null | string => { const useSongUrl = (
transcode: TranscodingConfig,
current: boolean,
song?: QueueSong,
): null | string => {
const prior = useRef(['', '']); const prior = useRef(['', '']);
return useMemo(() => { return useMemo(() => {
if (song?.serverId) { if (song?._serverId) {
// If we are the current track, we do not want a transcoding // If we are the current track, we do not want a transcoding
// reconfiguration to force a restart. // reconfiguration to force a restart.
if (current && prior.current[0] === song.uniqueId) { if (current && prior.current[0] === song._uniqueId) {
return prior.current[1]; return prior.current[1];
} }
if (!transcode.enabled) { if (!transcode.enabled) {
// transcoding disabled; save the result // transcoding disabled; save the result
prior.current = [song.uniqueId, song.streamUrl]; prior.current = [song._uniqueId, song.streamUrl];
return song.streamUrl; return song.streamUrl;
} }
const result = api.controller.getTranscodingUrl({ const result = api.controller.getTranscodingUrl({
apiClientProps: { apiClientProps: {
serverId: song.serverId, serverId: song._serverId,
}, },
query: { query: {
base: song.streamUrl, base: song.streamUrl,
@@ -85,14 +94,14 @@ const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song)
})!; })!;
// transcoding enabled; save the updated result // transcoding enabled; save the updated result
prior.current = [song.uniqueId, result]; prior.current = [song._uniqueId, result];
return result; return result;
} }
// no track; clear result // no track; clear result
prior.current = ['', '']; prior.current = ['', ''];
return null; return null;
}, [current, song?.uniqueId, song?.serverId, song?.streamUrl, transcode]); }, [current, song?._uniqueId, song?._serverId, song?.streamUrl, transcode]);
}; };
export interface AudioPlayerRef { export interface AudioPlayerRef {
@@ -122,7 +131,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio); const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio);
const preservesPitch = useSettingsStore((state) => state.playback.preservePitch); const preservesPitch = useSettingsStore((state) => state.playback.preservePitch);
const { resetSampleRate } = useSettingsStoreActions(); const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed(); const playbackSpeed = usePlayerSpeed();
const { transcode } = usePlaybackSettings(); const { transcode } = usePlaybackSettings();
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1); const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
@@ -448,7 +457,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
onEnded={stream1 ? handleOnEnded : undefined} onEnded={stream1 ? handleOnEnded : undefined}
onError={handleOnError(player1Ref)} onError={handleOnError(player1Ref)}
onProgress={ onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1 playbackStyle === PlayerStyle.GAPLESS ? handleGapless1 : handleCrossfade1
} }
onReady={handlePlayer1Start} onReady={handlePlayer1Start}
playbackRate={playbackSpeed} playbackRate={playbackSpeed}
@@ -468,7 +477,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
onEnded={stream2 ? handleOnEnded : undefined} onEnded={stream2 ? handleOnEnded : undefined}
onError={handleOnError(player2Ref)} onError={handleOnError(player2Ref)}
onProgress={ onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2 playbackStyle === PlayerStyle.GAPLESS ? handleGapless2 : handleCrossfade2
} }
onReady={handlePlayer2Start} onReady={handlePlayer2Start}
playbackRate={playbackSpeed} playbackRate={playbackSpeed}
@@ -0,0 +1,126 @@
import { useCallback, useRef, useState } from 'react';
import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
import { useMainPlayerListener } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import {
usePlayerActions,
usePlayerData,
usePlayerMuted,
usePlayerProperties,
usePlayerVolume,
} from '/@/renderer/store';
import { PlayerStatus } from '/@/shared/types/types';
const PLAY_PAUSE_FADE_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10;
export function MpvPlayer() {
const playerRef = useRef<MpvPlayerEngineHandle>(null);
const { currentSong, nextSong, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { speed } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState(false);
const fadeAndSetStatus = useCallback(
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
if (isTransitioning) {
return setLocalPlayerStatus(status);
}
const steps = duration / PLAY_PAUSE_FADE_INTERVAL;
const volumeStep = (endVolume - startVolume) / steps;
let currentStep = 0;
const promise = new Promise((resolve) => {
const interval = setInterval(() => {
currentStep++;
const newVolume = startVolume + volumeStep * currentStep;
playerRef.current?.setVolume(newVolume);
if (currentStep >= steps) {
clearInterval(interval);
setIsTransitioning(false);
resolve(true);
}
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
}
},
[isTransitioning],
);
const onProgress = useCallback(
(e: PlayerOnProgressProps) => {
setTimestamp(Number(e.playedSeconds.toFixed(0)));
},
[setTimestamp],
);
const handleOnEnded = useCallback(() => {
// When mpv auto-advances to the next song (position 1 becomes position 0),
// we need to update the player store first, then update the mpv queue with the new next song
// This follows the same pattern as the old useCenterControls implementation
const playerData = mediaAutoNext();
// Update the mpv queue with the new next song
// The engine will handle the queue update through the useEffect when nextSong changes
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
return playerData;
}, [mediaAutoNext, volume, setIsTransitioning]);
usePlayerEvents(
{
onPlayerSeekToTimestamp: (properties) => {
const timestamp = properties.timestamp;
playerRef.current?.seekTo(timestamp);
},
onPlayerStatus: async (properties) => {
const status = properties.status;
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
}
},
onPlayerVolume: (properties) => {
const volume = properties.volume;
playerRef.current?.setVolume(volume);
},
},
[volume, isTransitioning, fadeAndSetStatus],
);
useMainPlayerListener();
return (
<MpvPlayerEngine
currentSrc={currentSong?.streamUrl}
isMuted={isMuted}
isTransitioning={isTransitioning}
nextSrc={nextSong?.streamUrl}
onEnded={handleOnEnded}
onProgress={onProgress}
playerRef={playerRef}
playerStatus={localPlayerStatus}
speed={speed}
volume={volume}
/>
);
}
@@ -0,0 +1,13 @@
export interface AudioPlayer {
decreaseVolume(by: number): void;
increaseVolume(by: number): void;
pause(): void;
play(): void;
seekTo(seekTo: number): void;
setVolume(volume: number): void;
}
export interface PlayerOnProgressProps {
played: number;
playedSeconds: number;
}
@@ -0,0 +1,3 @@
export const convertToLogVolume = (linearVolume: number) => {
return Math.pow(linearVolume, 2.0);
};
@@ -0,0 +1,330 @@
import type { Dispatch } from 'react';
import type ReactPlayer from 'react-player';
import { useCallback, useRef, useState } from 'react';
import {
WebPlayerEngine,
WebPlayerEngineHandle,
} from '/@/renderer/features/player/audio-player/engine/web-player-engine';
import { useMainPlayerListener } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import {
usePlayerActions,
usePlayerData,
usePlayerMuted,
usePlayerProperties,
usePlayerVolume,
} from '/@/renderer/store';
import { PlayerStatus, PlayerStyle } from '/@/shared/types/types';
const PLAY_PAUSE_FADE_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10;
export function WebPlayer() {
const playerRef = useRef<WebPlayerEngineHandle>(null);
const { num, player1, player2, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { crossfadeDuration, speed, transitionType } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState<boolean | string>(false);
const fadeAndSetStatus = useCallback(
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
if (isTransitioning) {
return setLocalPlayerStatus(status);
}
const steps = duration / PLAY_PAUSE_FADE_INTERVAL;
const volumeStep = (endVolume - startVolume) / steps;
let currentStep = 0;
const promise = new Promise((resolve) => {
const interval = setInterval(() => {
currentStep++;
const newVolume = startVolume + volumeStep * currentStep;
playerRef.current?.setVolume(newVolume);
if (currentStep >= steps) {
clearInterval(interval);
setIsTransitioning(false);
resolve(true);
}
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
}
},
[isTransitioning],
);
const onProgressPlayer1 = useCallback(
(e: PlayerOnProgressProps) => {
if (transitionType === 'crossfade' && num === 1) {
setTimestamp(Number(e.playedSeconds.toFixed(0)));
} else if (transitionType === 'gapless') {
setTimestamp(Number(e.playedSeconds.toFixed(0)));
}
if (!playerRef.current?.player1()) {
return;
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
crossfadeDuration: crossfadeDuration,
currentPlayer: playerRef.current.player1(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
isTransitioning,
nextPlayer: playerRef.current.player2(),
playerNum: 1,
setIsTransitioning,
volume,
});
break;
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player2(),
setIsTransitioning,
});
break;
}
},
[crossfadeDuration, isTransitioning, num, setTimestamp, transitionType, volume],
);
const onProgressPlayer2 = useCallback(
(e: PlayerOnProgressProps) => {
if (transitionType === PlayerStyle.CROSSFADE && num === 2) {
setTimestamp(Number(e.playedSeconds.toFixed(0)));
} else if (transitionType === PlayerStyle.GAPLESS) {
setTimestamp(Number(e.playedSeconds.toFixed(0)));
}
if (!playerRef.current?.player2()) {
return;
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
crossfadeDuration: crossfadeDuration,
currentPlayer: playerRef.current.player2(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
isTransitioning,
nextPlayer: playerRef.current.player1(),
playerNum: 2,
setIsTransitioning,
volume,
});
break;
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player1(),
setIsTransitioning,
});
break;
}
},
[crossfadeDuration, isTransitioning, num, setTimestamp, transitionType, volume],
);
const handleOnEndedPlayer1 = useCallback(() => {
const promise = new Promise((resolve) => {
mediaAutoNext();
resolve(true);
});
promise.then(() => {
playerRef.current?.player1()?.ref?.getInternalPlayer().pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
}, [mediaAutoNext, volume]);
const handleOnEndedPlayer2 = useCallback(() => {
const promise = new Promise((resolve) => {
mediaAutoNext();
resolve(true);
});
promise.then(() => {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
}, [mediaAutoNext, volume]);
usePlayerEvents(
{
onPlayerSeekToTimestamp: (properties) => {
const timestamp = properties.timestamp;
if (num === 1) {
playerRef.current?.player1()?.ref?.seekTo(timestamp);
} else {
playerRef.current?.player2()?.ref?.seekTo(timestamp);
}
},
onPlayerStatus: async (properties) => {
const status = properties.status;
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
}
},
onPlayerVolume: (properties) => {
const volume = properties.volume;
playerRef.current?.setVolume(volume);
},
},
[volume, num, isTransitioning],
);
useMainPlayerListener();
return (
<WebPlayerEngine
isMuted={isMuted}
isTransitioning={Boolean(isTransitioning)}
onEndedPlayer1={handleOnEndedPlayer1}
onEndedPlayer2={handleOnEndedPlayer2}
onProgressPlayer1={onProgressPlayer1}
onProgressPlayer2={onProgressPlayer2}
playerNum={num}
playerRef={playerRef}
playerStatus={localPlayerStatus}
speed={speed}
src1={player1?.streamUrl}
src2={player2?.streamUrl}
volume={volume}
/>
);
}
function crossfadeHandler(args: {
crossfadeDuration: number;
currentPlayer: {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
currentPlayerNum: number;
currentTime: number;
duration: number;
isTransitioning: boolean | string;
nextPlayer: {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
playerNum: number;
setIsTransitioning: Dispatch<boolean | string>;
volume: number;
}) {
const {
crossfadeDuration,
currentPlayer,
currentPlayerNum,
currentTime,
duration,
isTransitioning,
nextPlayer,
playerNum,
setIsTransitioning,
volume,
} = args;
const player = `player${playerNum}`;
if (!isTransitioning) {
if (currentTime > duration - crossfadeDuration) {
nextPlayer.setVolume(0);
nextPlayer.ref?.getInternalPlayer().play();
return setIsTransitioning(player);
}
return;
}
if (isTransitioning !== player && currentPlayerNum !== playerNum) {
return;
}
const timeLeft = duration - currentTime;
// Calculate the volume levels based on time remaining
const currentPlayerVolume = (timeLeft / crossfadeDuration) * volume;
const nextPlayerVolume = ((crossfadeDuration - timeLeft) / crossfadeDuration) * volume;
// Set volumes for both players
currentPlayer.setVolume(currentPlayerVolume);
nextPlayer.setVolume(nextPlayerVolume);
}
function gaplessHandler(args: {
currentTime: number;
duration: number;
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
setIsTransitioning: Dispatch<boolean | string>;
}) {
const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;
if (!isTransitioning) {
if (currentTime > duration - 2) {
return setIsTransitioning(true);
}
return null;
}
const durationPadding = getDurationPadding(isFlac);
if (currentTime + durationPadding >= duration) {
return nextPlayer.ref
?.getInternalPlayer()
?.play()
.catch(() => {});
}
return null;
}
function getDuration(ref: null | ReactPlayer | undefined) {
return ref?.getInternalPlayer()?.duration || 0;
}
function getDurationPadding(isFlac: boolean) {
switch (isFlac) {
case false:
return 0.116;
case true:
return 0.065;
}
}
@@ -4,31 +4,6 @@
align-items: center; align-items: center;
} }
.slider-container {
display: flex;
width: 95%;
height: 20px;
}
.slider-value-wrapper {
display: flex;
flex: 1;
align-self: center;
justify-content: center;
max-width: 50px;
@media (width < 768px) {
display: none;
}
}
.slider-wrapper {
display: flex;
flex: 6;
align-items: center;
height: 100%;
}
.controls-container { .controls-container {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,8 +1,4 @@
import { useHotkeys } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './center-controls.module.css'; import styles from './center-controls.module.css';
@@ -10,129 +6,48 @@ import styles from './center-controls.module.css';
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button'; import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal'; import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { useCenterControls } from '/@/renderer/features/player/hooks/use-center-controls'; import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { useMediaSession } from '/@/renderer/features/player/hooks/use-media-session';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { import {
useAppStore, usePlayerRepeat,
useAppStoreActions, usePlayerShuffle,
useCurrentPlayer, usePlayerSong,
useCurrentSong, usePlayerStatus,
useCurrentStatus,
useCurrentTime,
useHotkeySettings,
usePlaybackType,
useRepeatStatus,
useSetCurrentTime,
useSettingsStore, useSettingsStore,
useShuffleStatus,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text'; import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
interface CenterControlsProps { export const CenterControls = () => {
playersRef: any;
}
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false); const currentSong = usePlayerSong();
const currentSong = useCurrentSong();
const skip = useSettingsStore((state) => state.general.skipButtons); const skip = useSettingsStore((state) => state.general.skipButtons);
const buttonSize = useSettingsStore((state) => state.general.buttonSize); const buttonSize = useSettingsStore((state) => state.general.buttonSize);
const playbackType = usePlaybackType(); const status = usePlayerStatus();
const player1 = playersRef?.current?.player1; const repeat = usePlayerRepeat();
const player2 = playersRef?.current?.player2; const shuffle = usePlayerShuffle();
const status = useCurrentStatus();
const player = useCurrentPlayer();
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const { bindings } = useHotkeySettings();
const { showTimeRemaining } = useAppStore();
const { setShowTimeRemaining } = useAppStoreActions();
const { const {
handleNextTrack, mediaNext,
handlePause, mediaPrevious,
handlePlay, mediaSkipBackward,
handlePlayPause, mediaSkipForward,
handlePrevTrack, mediaStop,
handleSeekSlider, mediaTogglePlayPause,
handleSkipBackward, toggleRepeat,
handleSkipForward, toggleShuffle,
handleStop, } = usePlayerContext();
handleToggleRepeat,
handleToggleShuffle,
} = useCenterControls({ playersRef });
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const formattedDuration = formatDuration(songDuration * 1000 || 0);
const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0);
const formattedTime = formatDuration(currentTime * 1000 || 0);
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playbackType === PlaybackType.WEB) {
// Update twice a second for slightly better performance
interval = setInterval(() => {
if (currentPlayerRef) {
setCurrentTime(currentPlayerRef.getCurrentTime());
}
}, 500);
}
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playbackType, status]);
const [seekValue, setSeekValue] = useState(0);
useHotkeys([
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
[
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
],
[
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
() => handleSkipForward(skip?.skipForwardSeconds || 5),
],
]);
useMediaSession({
handleNextTrack,
handlePause,
handlePlay,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
});
return ( return (
<> <>
<div className={styles.controlsContainer}> <div className={styles.controlsContainer}>
<div className={styles.buttonsContainer}> <div className={styles.buttonsContainer}>
<PlayerButton <PlayerButton
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />} icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
onClick={handleStop} onClick={mediaStop}
tooltip={{ tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }), label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 0, openDelay: 0,
@@ -148,7 +63,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/> />
} }
isActive={shuffle !== PlayerShuffle.NONE} isActive={shuffle !== PlayerShuffle.NONE}
onClick={handleToggleShuffle} onClick={toggleShuffle}
tooltip={{ tooltip={{
label: label:
shuffle === PlayerShuffle.NONE shuffle === PlayerShuffle.NONE
@@ -163,7 +78,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/> />
<PlayerButton <PlayerButton
icon={<Icon fill="default" icon="mediaPrevious" size={buttonSize} />} icon={<Icon fill="default" icon="mediaPrevious" size={buttonSize} />}
onClick={handlePrevTrack} onClick={mediaPrevious}
tooltip={{ tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }), label: t('player.previous', { postProcess: 'sentenceCase' }),
openDelay: 0, openDelay: 0,
@@ -175,7 +90,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
icon={ icon={
<Icon fill="default" icon="mediaStepBackward" size={buttonSize} /> <Icon fill="default" icon="mediaStepBackward" size={buttonSize} />
} }
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)} onClick={mediaSkipBackward}
tooltip={{ tooltip={{
label: t('player.skip', { label: t('player.skip', {
context: 'back', context: 'back',
@@ -190,12 +105,12 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayButton <PlayButton
disabled={currentSong?.id === undefined} disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED} isPaused={status === PlayerStatus.PAUSED}
onClick={handlePlayPause} onClick={mediaTogglePlayPause}
/> />
{skip?.enabled && ( {skip?.enabled && (
<PlayerButton <PlayerButton
icon={<Icon fill="default" icon="mediaStepForward" size={buttonSize} />} icon={<Icon fill="default" icon="mediaStepForward" size={buttonSize} />}
onClick={() => handleSkipForward(skip?.skipForwardSeconds)} onClick={mediaSkipForward}
tooltip={{ tooltip={{
label: t('player.skip', { label: t('player.skip', {
context: 'forward', context: 'forward',
@@ -209,7 +124,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
)} )}
<PlayerButton <PlayerButton
icon={<Icon fill="default" icon="mediaNext" size={buttonSize} />} icon={<Icon fill="default" icon="mediaNext" size={buttonSize} />}
onClick={handleNextTrack} onClick={mediaNext}
tooltip={{ tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }), label: t('player.next', { postProcess: 'sentenceCase' }),
openDelay: 0, openDelay: 0,
@@ -229,7 +144,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
) )
} }
isActive={repeat !== PlayerRepeat.NONE} isActive={repeat !== PlayerRepeat.NONE}
onClick={handleToggleRepeat} onClick={toggleRepeat}
tooltip={{ tooltip={{
label: `${ label: `${
repeat === PlayerRepeat.NONE repeat === PlayerRepeat.NONE
@@ -267,57 +182,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/> />
</div> </div>
</div> </div>
<div className={styles.sliderContainer}> <PlayerbarSlider />
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.elapsedTime}
fw={600}
isMuted
isNoSelect
size="xs"
style={{ userSelect: 'none' }}
>
{formattedTime}
</Text>
</div>
<div className={styles.sliderWrapper}>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
// There is a timing bug in Mantine in which the onChangeEnd
// event fires before onChange. Add a small delay to force
// onChangeEnd to happen after onCHange
setTimeout(() => {
handleSeekSlider(e);
setIsSeeking(false);
}, 50);
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.totalDuration}
fw={600}
isMuted
isNoSelect
onClick={() => setShowTimeRemaining(!showTimeRemaining)}
role="button"
size="xs"
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{showTimeRemaining ? formattedTimeRemaining : formattedDuration}
</Text>
</div>
</div>
</> </>
); );
}; };
@@ -15,7 +15,7 @@ import {
} from '/@/renderer/store/full-screen-player.store'; } from '/@/renderer/store/full-screen-player.store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const Visualizer = lazy(() => const Visualizer = lazy(() =>
import('/@/renderer/features/player/components/visualizer').then((module) => ({ import('/@/renderer/features/player/components/visualizer').then((module) => ({
@@ -48,7 +48,7 @@ export const FullScreenPlayerQueue = () => {
}, },
]; ];
if (type === PlaybackType.WEB && webAudio) { if (type === PlayerType.WEB && webAudio) {
items.push({ items.push({
active: activeTab === 'visualizer', active: activeTab === 'visualizer',
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }), label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
@@ -107,7 +107,7 @@ export const FullScreenPlayerQueue = () => {
</div> </div>
) : activeTab === 'lyrics' ? ( ) : activeTab === 'lyrics' ? (
<Lyrics /> <Lyrics />
) : activeTab === 'visualizer' && type === PlaybackType.WEB && webAudio ? ( ) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
<Visualizer /> <Visualizer />
</Suspense> </Suspense>
@@ -11,7 +11,7 @@ import { FullScreenPlayerImage } from '/@/renderer/features/player/components/fu
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue'; import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
import { useFastAverageColor } from '/@/renderer/hooks'; import { useFastAverageColor } from '/@/renderer/hooks';
import { import {
useCurrentSong, usePlayerSong,
useFullScreenPlayerStore, useFullScreenPlayerStore,
useFullScreenPlayerStoreActions, useFullScreenPlayerStoreActions,
useLyricsSettings, useLyricsSettings,
@@ -421,7 +421,7 @@ export const FullScreenPlayer = () => {
isOpenedRef.current = true; isOpenedRef.current = true;
}, [location, setStore]); }, [location, setStore]);
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const { background } = useFastAverageColor({ const { background } = useFastAverageColor({
algorithm: 'dominant', algorithm: 'dominant',
src: currentSong?.imageUrl, src: currentSong?.imageUrl,
@@ -1,8 +1,8 @@
import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list'; import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list';
import { useCurrentSong } from '/@/renderer/store'; import { usePlayerSong } from '/@/renderer/store';
export const FullScreenSimilarSongs = () => { export const FullScreenSimilarSongs = () => {
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
return currentSong?.id ? <SimilarSongsList fullScreen song={currentSong} /> : null; return currentSong?.id ? <SimilarSongsList fullScreen song={currentSong} /> : null;
}; };
@@ -12,9 +12,9 @@ import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/h
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useAppStoreActions, useAppStoreActions,
useCurrentSong,
useFullScreenPlayerStore, useFullScreenPlayerStore,
useHotkeySettings, useHotkeySettings,
usePlayerSong,
useSetFullScreenPlayerStore, useSetFullScreenPlayerStore,
useSidebarStore, useSidebarStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
@@ -34,7 +34,7 @@ export const LeftControls = () => {
const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { collapsed, image } = useSidebarStore(); const { collapsed, image } = useSidebarStore();
const hideImage = image && !collapsed; const hideImage = image && !collapsed;
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const title = currentSong?.name; const title = currentSong?.name;
const artists = currentSong?.artists; const artists = currentSong?.artists;
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
@@ -49,3 +49,28 @@
right: calc(0.1rem * -1); right: calc(0.1rem * -1);
} }
} }
.slider-container {
display: flex;
width: 95%;
height: 20px;
}
.slider-value-wrapper {
display: flex;
flex: 1;
align-self: center;
justify-content: center;
max-width: 50px;
@media (width < 768px) {
display: none;
}
}
.slider-wrapper {
display: flex;
flex: 6;
align-items: center;
height: 100%;
}
@@ -1,8 +1,128 @@
import formatDuration from 'format-duration';
import { useEffect, useRef, useState } from 'react';
import styles from './playerbar-slider.module.css'; import styles from './playerbar-slider.module.css';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { useRemote } from '/@/renderer/features/remote/hooks/use-remote';
import {
useAppStore,
useAppStoreActions,
usePlaybackType,
usePlayerSong,
usePlayerTimestamp,
} from '/@/renderer/store';
import { Slider, SliderProps } from '/@/shared/components/slider/slider'; import { Slider, SliderProps } from '/@/shared/components/slider/slider';
import { Text } from '/@/shared/components/text/text';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlayerType } from '/@/shared/types/types';
export const PlayerbarSlider = ({ ...props }: SliderProps) => { export const PlayerbarSlider = ({ ...props }: SliderProps) => {
const playbackType = usePlaybackType();
const currentSong = usePlayerSong();
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const [isSeeking, setIsSeeking] = useState(false);
const [seekValue, setSeekValue] = useState(0);
const currentTime = usePlayerTimestamp();
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const formattedDuration = formatDuration(songDuration * 1000 || 0);
const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0);
const formattedTime = formatDuration(currentTime * 1000 || 0);
const { showTimeRemaining } = useAppStore();
const { setShowTimeRemaining } = useAppStoreActions();
const { mediaSeekToTimestamp } = usePlayerContext();
const handleSeekToTimestamp = (timestamp: number) => {
mediaSeekToTimestamp(timestamp);
};
useEffect(() => {
return () => {
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
}
};
}, []);
useRemote();
return (
<>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.elapsedTime}
fw={600}
isMuted
isNoSelect
size="xs"
style={{ userSelect: 'none' }}
>
{formattedTime}
</Text>
</div>
<div className={styles.sliderWrapper}>
<CustomPlayerbarSlider
{...props}
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
// Cancel any pending timeout if user starts seeking again
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
seekTimeoutRef.current = null;
}
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
setSeekValue(e);
handleSeekToTimestamp(e);
// Delay resetting isSeeking to allow currentTime to catch up
// This prevents the slider from flickering back and forth
seekTimeoutRef.current = setTimeout(() => {
setIsSeeking(false);
seekTimeoutRef.current = null;
}, 300);
}}
onClick={(e) => {
e?.stopPropagation();
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.totalDuration}
fw={600}
isMuted
isNoSelect
onClick={() => setShowTimeRemaining(!showTimeRemaining)}
role="button"
size="xs"
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{showTimeRemaining ? formattedTimeRemaining : formattedDuration}
</Text>
</div>
</div>
{playbackType === PlayerType.WEB && <WebPlayer />}
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
</>
);
};
export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
return ( return (
<Slider <Slider
classNames={{ classNames={{
@@ -13,9 +133,7 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
track: styles.track, track: styles.track,
}} }}
{...props} {...props}
onClick={(e) => { size={6}
e?.stopPropagation();
}}
/> />
); );
}; };
@@ -1,46 +1,18 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { MouseEvent, useCallback } from 'react'; import { MouseEvent } from 'react';
import styles from './playerbar.module.css'; import styles from './playerbar.module.css';
import { AudioPlayer } from '/@/renderer/components/audio-player';
import { CenterControls } from '/@/renderer/features/player/components/center-controls'; import { CenterControls } from '/@/renderer/features/player/components/center-controls';
import { LeftControls } from '/@/renderer/features/player/components/left-controls'; import { LeftControls } from '/@/renderer/features/player/components/left-controls';
import { RightControls } from '/@/renderer/features/player/components/right-controls'; import { RightControls } from '/@/renderer/features/player/components/right-controls';
import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker'; import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; import { useFullScreenPlayerStore, useSetFullScreenPlayerStore } from '/@/renderer/store';
import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { useGeneralSettings } from '/@/renderer/store/settings.store';
import {
useCurrentPlayer,
useCurrentStatus,
useFullScreenPlayerStore,
useMuted,
usePlayer1Data,
usePlayer2Data,
usePlayerControls,
useSetFullScreenPlayerStore,
useVolume,
} from '/@/renderer/store';
import {
useGeneralSettings,
usePlaybackType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors'; import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlaybackType } from '/@/shared/types/types';
export const Playerbar = () => { export const Playerbar = () => {
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
const { playerbarOpenDrawer } = useGeneralSettings(); const { playerbarOpenDrawer } = useGeneralSettings();
const playbackType = usePlaybackType();
const volume = useVolume();
const player1 = usePlayer1Data();
const player2 = usePlayer2Data();
const status = useCurrentStatus();
const player = useCurrentPlayer();
const muted = useMuted();
const { autoNext } = usePlayerControls();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
@@ -51,11 +23,6 @@ export const Playerbar = () => {
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
}; };
const autoNextFn = useCallback(() => {
const playerData = autoNext();
updateSong(playerData.current.song);
}, [autoNext]);
return ( return (
<div <div
className={clsx(styles.container, PlaybackSelectors.mediaPlayer)} className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}
@@ -66,15 +33,15 @@ export const Playerbar = () => {
<LeftControls /> <LeftControls />
</div> </div>
<div className={styles.centerGridItem}> <div className={styles.centerGridItem}>
<CenterControls playersRef={playersRef} /> <CenterControls />
</div> </div>
<div className={styles.rightGridItem}> <div className={styles.rightGridItem}>
<RightControls /> <RightControls />
</div> </div>
</div> </div>
{playbackType === PlaybackType.WEB && ( {/* {playbackType === PlayerType.WEB && (
<AudioPlayer <AudioPlayer
autoNext={autoNextFn} // autoNext={autoNextFn}
crossfadeDuration={settings.crossfadeDuration} crossfadeDuration={settings.crossfadeDuration}
crossfadeStyle={settings.crossfadeStyle} crossfadeStyle={settings.crossfadeStyle}
currentPlayer={player} currentPlayer={player}
@@ -87,7 +54,7 @@ export const Playerbar = () => {
style={settings.style as any} style={settings.style as any}
volume={(volume / 100) ** 2} volume={(volume / 100) ** 2}
/> />
)} )} */}
</div> </div>
); );
}; };
@@ -1,27 +1,27 @@
import { useHotkeys, useMediaQuery } from '@mantine/hooks'; import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect } from 'react'; import { useCallback, useEffect, WheelEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { useRightControls } from '/@/renderer/features/player/hooks/use-right-controls'; import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { import {
useAppStoreActions, useAppStoreActions,
useCurrentServer, useCurrentServer,
useCurrentSong, useGeneralSettings,
useHotkeySettings, useHotkeySettings,
useMuted,
usePlaybackSettings, usePlaybackSettings,
usePlaybackType, usePlaybackType,
usePreviousSong, usePlayerData,
usePlayerMuted,
usePlayerSpeed,
usePlayerVolume,
useSettingsStore, useSettingsStore,
useSettingsStoreActions, useSettingsStoreActions,
useSidebarStore, useSidebarStore,
useSpeed,
useVolume,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
@@ -32,38 +32,53 @@ import { Rating } from '/@/shared/components/rating/rating';
import { Slider } from '/@/shared/components/slider/slider'; import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null; const remote = isElectron() ? window.api.remote : null;
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
if (newVolumeGreaterThanHundred) {
volumeToSet = 100;
} else {
volumeToSet = volume + volumeWheelStep;
}
return volumeToSet;
};
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
if (newVolumeLessThanZero) {
volumeToSet = 0;
} else {
volumeToSet = volume - volumeWheelStep;
}
return volumeToSet;
};
export const RightControls = () => { export const RightControls = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const isMinWidth = useMediaQuery('(max-width: 480px)'); const isMinWidth = useMediaQuery('(max-width: 480px)');
const volume = useVolume(); const volume = usePlayerVolume();
const muted = useMuted(); const muted = usePlayerMuted();
const server = useCurrentServer(); const server = useCurrentServer();
const currentSong = useCurrentSong(); const { currentSong, previousSong } = usePlayerData();
const previousSong = usePreviousSong();
const { setSideBar } = useAppStoreActions(); const { setSideBar } = useAppStoreActions();
const { rightExpanded: isQueueExpanded } = useSidebarStore(); const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const {
handleMute,
handleSpeed,
handleVolumeDown,
handleVolumeSlider,
handleVolumeUp,
handleVolumeWheel,
} = useRightControls();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const playbackSettings = usePlaybackSettings(); const playbackSettings = usePlaybackSettings();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const { volumeWheelStep } = useGeneralSettings();
const speed = useSpeed(); const speed = usePlayerSpeed();
const volumeWidth = useSettingsStore((state) => state.general.volumeWidth); const volumeWidth = useSettingsStore((state) => state.general.volumeWidth);
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch); const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
const { mediaToggleMute, setSpeed, setVolume } = usePlayerContext();
const updateRatingMutation = useSetRating({}); const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({}); const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({}); const removeFromFavoritesMutation = useDeleteFavorite({});
@@ -72,7 +87,7 @@ export const RightControls = () => {
if (!song?.id) return; if (!song?.id) return;
addToFavoritesMutation.mutate({ addToFavoritesMutation.mutate({
apiClientProps: { serverId: song?.serverId || '' }, apiClientProps: { serverId: song?._serverId || '' },
query: { query: {
id: [song.id], id: [song.id],
type: LibraryItem.SONG, type: LibraryItem.SONG,
@@ -84,7 +99,7 @@ export const RightControls = () => {
if (!currentSong) return; if (!currentSong) return;
updateRatingMutation.mutate({ updateRatingMutation.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' }, apiClientProps: { serverId: currentSong?._serverId || '' },
query: { query: {
item: [currentSong], item: [currentSong],
rating, rating,
@@ -96,7 +111,7 @@ export const RightControls = () => {
if (!song?.id) return; if (!song?.id) return;
removeFromFavoritesMutation.mutate({ removeFromFavoritesMutation.mutate({
apiClientProps: { serverId: song?.serverId || '' }, apiClientProps: { serverId: song?._serverId || '' },
query: { query: {
id: [song.id], id: [song.id],
type: LibraryItem.SONG, type: LibraryItem.SONG,
@@ -114,6 +129,46 @@ export const RightControls = () => {
} }
}; };
const handleVolumeDown = useCallback(() => {
setVolume(volume - 1);
}, [setVolume, volume]);
const handleVolumeUp = useCallback(() => {
setVolume(volume + 1);
}, [setVolume, volume]);
const handleMute = useCallback(() => {
mediaToggleMute();
}, [mediaToggleMute]);
const handleSpeed = useCallback(
(e: number) => {
setSpeed(e);
},
[setSpeed],
);
const handleVolumeSlider = useCallback(
(e: number) => {
setVolume(e);
},
[setVolume],
);
const handleVolumeWheel = useCallback(
(e: WheelEvent<HTMLButtonElement | HTMLDivElement>) => {
let volumeToSet;
if (e.deltaY > 0 || e.deltaX > 0) {
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
} else {
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
}
setVolume(volumeToSet);
},
[setVolume, volume, volumeWheelStep],
);
const handleToggleQueue = () => { const handleToggleQueue = () => {
setSideBar({ rightExpanded: !isQueueExpanded }); setSideBar({ rightExpanded: !isQueueExpanded });
}; };
@@ -187,9 +242,9 @@ export const RightControls = () => {
query: { query: {
item: [ item: [
{ {
_serverId: currentSong?._serverId || '',
id, id,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
serverId,
} as Song, // This is not a type-safe cast, but it works because those are all the prop } as Song, // This is not a type-safe cast, but it works because those are all the prop
], ],
rating, rating,
@@ -204,7 +259,12 @@ export const RightControls = () => {
} }
return () => {}; return () => {};
}, [addToFavoritesMutation, removeFromFavoritesMutation, updateRatingMutation]); }, [
addToFavoritesMutation,
currentSong?._serverId,
removeFromFavoritesMutation,
updateRatingMutation,
]);
return ( return (
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem"> <Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
@@ -237,7 +297,7 @@ export const RightControls = () => {
/> />
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown> <DropdownMenu.Dropdown>
{playbackType === PlaybackType.WEB && ( {playbackType === PlayerType.WEB && (
<Option> <Option>
<Option.Label> <Option.Label>
{t('setting.preservePitch', { {t('setting.preservePitch', {
@@ -339,7 +399,7 @@ export const RightControls = () => {
variant="subtle" variant="subtle"
/> />
{!isMinWidth ? ( {!isMinWidth ? (
<PlayerbarSlider <CustomPlayerbarSlider
max={100} max={100}
min={0} min={0}
onChange={handleVolumeSlider} onChange={handleVolumeSlider}
@@ -0,0 +1,467 @@
import { QueryClient, useQueryClient } from '@tanstack/react-query';
import { createContext, useCallback, useContext, useMemo } from 'react';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AddToQueueType, usePlayerActions } from '/@/renderer/store';
import {
LibraryItem,
PlaylistSongListResponse,
QueueSong,
Song,
SongListResponse,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
interface PlayerContext {
addToQueueByData: (data: Song[], type: AddToQueueType) => void;
addToQueueByFetch: (
serverId: string,
id: string[],
itemType: LibraryItem,
type: AddToQueueType,
) => void;
clearQueue: () => void;
clearSelected: (items: QueueSong[]) => void;
decreaseVolume: (amount: number) => void;
increaseVolume: (amount: number) => void;
mediaNext: () => void;
mediaPause: () => void;
mediaPlay: (id?: string) => void;
mediaPrevious: () => void;
mediaSeekToTimestamp: (timestamp: number) => void;
mediaSkipBackward: () => void;
mediaSkipForward: () => void;
mediaStop: () => void;
mediaToggleMute: () => void;
mediaTogglePlayPause: () => void;
moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;
moveSelectedToBottom: (items: QueueSong[]) => void;
moveSelectedToNext: (items: QueueSong[]) => void;
moveSelectedToTop: (items: QueueSong[]) => void;
setRepeat: (repeat: PlayerRepeat) => void;
setShuffle: (shuffle: PlayerShuffle) => void;
setSpeed: (speed: number) => void;
setVolume: (volume: number) => void;
shuffle: () => void;
shuffleAll: () => void;
shuffleSelected: (items: QueueSong[]) => void;
toggleRepeat: () => void;
toggleShuffle: () => void;
}
export const PlayerContext = createContext<PlayerContext>({
addToQueueByData: () => {},
addToQueueByFetch: () => {},
clearQueue: () => {},
clearSelected: () => {},
decreaseVolume: () => {},
increaseVolume: () => {},
mediaNext: () => {},
mediaPause: () => {},
mediaPlay: () => {},
mediaPrevious: () => {},
mediaSeekToTimestamp: () => {},
mediaSkipBackward: () => {},
mediaSkipForward: () => {},
mediaStop: () => {},
mediaToggleMute: () => {},
mediaTogglePlayPause: () => {},
moveSelectedTo: () => {},
moveSelectedToBottom: () => {},
moveSelectedToNext: () => {},
moveSelectedToTop: () => {},
setRepeat: () => {},
setShuffle: () => {},
setSpeed: () => {},
setVolume: () => {},
shuffle: () => {},
shuffleAll: () => {},
shuffleSelected: () => {},
toggleRepeat: () => {},
toggleShuffle: () => {},
});
export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const queryClient = useQueryClient();
const storeActions = usePlayerActions();
const addToQueueByData = useCallback(
(data: Song[], type: AddToQueueType) => {
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
storeActions.addToQueueByUniqueId(data, type.uniqueId, edge);
} else {
storeActions.addToQueueByType(data, type as Play);
}
},
[storeActions],
);
const addToQueueByFetch = useCallback(
async (serverId: string, id: string[], itemType: LibraryItem, type: AddToQueueType) => {
const songs = await fetchSongsByItemType(queryClient, serverId, {
id,
itemType,
});
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
storeActions.addToQueueByUniqueId(songs, type.uniqueId, edge);
} else {
storeActions.addToQueueByType(songs, type as Play);
}
},
[queryClient, storeActions],
);
const clearQueue = useCallback(() => {
storeActions.clearQueue();
}, [storeActions]);
const clearSelected = useCallback(
(items: QueueSong[]) => {
storeActions.clearSelected(items);
},
[storeActions],
);
const decreaseVolume = useCallback(
(amount: number) => {
storeActions.decreaseVolume(amount);
},
[storeActions],
);
const increaseVolume = useCallback(
(amount: number) => {
storeActions.increaseVolume(amount);
},
[storeActions],
);
const mediaNext = useCallback(() => {
storeActions.mediaNext();
}, [storeActions]);
const mediaPause = useCallback(() => {
storeActions.mediaPause();
}, [storeActions]);
const mediaPlay = useCallback(
(id?: string) => {
storeActions.mediaPlay(id);
},
[storeActions],
);
const mediaPrevious = useCallback(() => {
storeActions.mediaPrevious();
}, [storeActions]);
const mediaStop = useCallback(() => {
storeActions.mediaStop();
}, [storeActions]);
const mediaSeekToTimestamp = useCallback(
(timestamp: number) => {
storeActions.mediaSeekToTimestamp(timestamp);
},
[storeActions],
);
const mediaSkipBackward = useCallback(() => {
storeActions.mediaSkipBackward();
}, [storeActions]);
const mediaSkipForward = useCallback(() => {
storeActions.mediaSkipForward();
}, [storeActions]);
const setSpeed = useCallback(
(speed: number) => {
storeActions.setSpeed(speed);
},
[storeActions],
);
const mediaToggleMute = useCallback(() => {
storeActions.mediaToggleMute();
}, [storeActions]);
const mediaTogglePlayPause = useCallback(() => {
storeActions.mediaTogglePlayPause();
}, [storeActions]);
const moveSelectedTo = useCallback(
(items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => {
storeActions.moveSelectedTo(items, uniqueId, edge);
},
[storeActions],
);
const moveSelectedToBottom = useCallback(
(items: QueueSong[]) => {
storeActions.moveSelectedToBottom(items);
},
[storeActions],
);
const moveSelectedToNext = useCallback(
(items: QueueSong[]) => {
storeActions.moveSelectedToNext(items);
},
[storeActions],
);
const moveSelectedToTop = useCallback(
(items: QueueSong[]) => {
storeActions.moveSelectedToTop(items);
},
[storeActions],
);
const setVolume = useCallback(
(volume: number) => {
storeActions.setVolume(volume);
},
[storeActions],
);
const setRepeat = useCallback(
(repeat: PlayerRepeat) => {
storeActions.setRepeat(repeat);
},
[storeActions],
);
const setShuffle = useCallback(
(shuffle: PlayerShuffle) => {
storeActions.setShuffle(shuffle);
},
[storeActions],
);
const shuffle = useCallback(() => {
storeActions.shuffle();
}, [storeActions]);
const shuffleAll = useCallback(() => {
storeActions.shuffleAll();
}, [storeActions]);
const shuffleSelected = useCallback(
(items: QueueSong[]) => {
storeActions.shuffleSelected(items);
},
[storeActions],
);
const toggleRepeat = useCallback(() => {
storeActions.toggleRepeat();
}, [storeActions]);
const toggleShuffle = useCallback(() => {
storeActions.toggleShuffle();
}, [storeActions]);
const contextValue: PlayerContext = useMemo(
() => ({
addToQueueByData,
addToQueueByFetch,
clearQueue,
clearSelected,
decreaseVolume,
increaseVolume,
mediaNext,
mediaPause,
mediaPlay,
mediaPrevious,
mediaSeekToTimestamp,
mediaSkipBackward,
mediaSkipForward,
mediaStop,
mediaToggleMute,
mediaTogglePlayPause,
moveSelectedTo,
moveSelectedToBottom,
moveSelectedToNext,
moveSelectedToTop,
setRepeat,
setShuffle,
setSpeed,
setVolume,
shuffle,
shuffleAll,
shuffleSelected,
toggleRepeat,
toggleShuffle,
}),
[
addToQueueByData,
addToQueueByFetch,
clearQueue,
clearSelected,
decreaseVolume,
setSpeed,
increaseVolume,
mediaNext,
mediaPause,
mediaPlay,
mediaPrevious,
mediaSeekToTimestamp,
mediaSkipBackward,
mediaSkipForward,
mediaStop,
mediaToggleMute,
mediaTogglePlayPause,
moveSelectedTo,
moveSelectedToBottom,
moveSelectedToNext,
moveSelectedToTop,
setRepeat,
setShuffle,
setVolume,
shuffle,
shuffleAll,
shuffleSelected,
toggleRepeat,
toggleShuffle,
],
);
return <PlayerContext.Provider value={contextValue}>{children}</PlayerContext.Provider>;
};
export const usePlayerContext = () => {
return useContext(PlayerContext);
};
/**
* Fetches the songs from the server
* @param queryClient - The query client to use to fetch the data
* @param serverId - The library id to use to fetch the data
* @param type - The type of the item to add to the queue
* @param args - The arguments to use to fetch the data
* @returns The songs to add to the queue
*/
export async function fetchSongsByItemType(
queryClient: QueryClient,
serverId: string,
args: {
id: string[];
itemType: LibraryItem;
params?: Record<string, any>;
},
) {
const songs: Song[] = [];
switch (args.itemType) {
case LibraryItem.ALBUM: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery(
songsQueries.list({
query: {
albumIds: [id],
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
break;
}
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery(
songsQueries.list({
query: {
albumArtistIds: [id],
limit: -1,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
break;
}
case LibraryItem.GENRE: {
const promises: Promise<SongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery(
songsQueries.list({
query: {
genreIds: [id],
limit: -1,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...args.params,
},
serverId: serverId,
}),
),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
break;
}
case LibraryItem.PLAYLIST: {
const promises: Promise<PlaylistSongListResponse>[] = [];
for (const id of args.id) {
promises.push(
queryClient.fetchQuery(
playlistsQueries.songList({
query: {
id: id,
...args.params,
},
serverId: serverId,
}),
),
);
}
const results = await Promise.all(promises);
songs.push(...results.flatMap((r) => r.items));
break;
}
}
return songs;
}
File diff suppressed because it is too large Load Diff
@@ -1,226 +1,226 @@
import { useQueryClient } from '@tanstack/react-query'; // import { useQueryClient } from '@tanstack/react-query';
import isElectron from 'is-electron'; // import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure'; // import { nanoid } from 'nanoid/non-secure';
import { useCallback, useRef } from 'react'; // import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; // import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys'; // import { queryKeys } from '/@/renderer/api/query-keys';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; // import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song'; // import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { // import {
getAlbumArtistSongsById, // getAlbumArtistSongsById,
getAlbumSongsById, // getAlbumSongsById,
getArtistSongsById, // getArtistSongsById,
getGenreSongsById, // getGenreSongsById,
getPlaylistSongsById, // getPlaylistSongsById,
getSongById, // getSongById,
getSongsByQuery, // getSongsByQuery,
} from '/@/renderer/features/player/utils'; // } from '/@/renderer/features/player/utils';
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store'; // import { useCurrentServer, usePlayerStore } from '/@/renderer/store';
import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store'; // import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; // import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
import { toast } from '/@/shared/components/toast/toast'; // import { toast } from '/@/shared/components/toast/toast';
import { // import {
instanceOfCancellationError, // instanceOfCancellationError,
LibraryItem, // LibraryItem,
QueueSong, // QueueSong,
Song, // Song,
SongListResponse, // SongListResponse,
} from '/@/shared/types/domain-types'; // } from '/@/shared/types/domain-types';
import { Play, PlaybackType, PlayQueueAddOptions } from '/@/shared/types/types'; // import { Play, PlaybackType, PlayQueueAddOptions } from '/@/shared/types/types';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => { // const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey; // let queryKey;
switch (itemType) { // switch (itemType) {
case LibraryItem.ALBUM: // case LibraryItem.ALBUM:
queryKey = queryKeys.songs.list(serverId); // queryKey = queryKeys.songs.list(serverId);
break; // break;
case LibraryItem.ALBUM_ARTIST: // case LibraryItem.ALBUM_ARTIST:
queryKey = queryKeys.songs.list(serverId); // queryKey = queryKeys.songs.list(serverId);
break; // break;
case LibraryItem.GENRE: // case LibraryItem.GENRE:
queryKey = queryKeys.songs.list(serverId); // queryKey = queryKeys.songs.list(serverId);
break; // break;
case LibraryItem.PLAYLIST: // case LibraryItem.PLAYLIST:
queryKey = queryKeys.playlists.songList(serverId); // queryKey = queryKeys.playlists.songList(serverId);
break; // break;
case LibraryItem.SONG: // case LibraryItem.SONG:
queryKey = queryKeys.songs.list(serverId); // queryKey = queryKeys.songs.list(serverId);
break; // break;
default: // default:
queryKey = queryKeys.songs.list(serverId); // queryKey = queryKeys.songs.list(serverId);
break; // break;
} // }
return queryKey; // return queryKey;
}; // };
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; // const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const addToQueue = usePlayerStore.getState().actions.addToQueue; // const addToQueue = usePlayerStore.getState().actions.addToQueue;
export const useHandlePlayQueueAdd = () => { // export const useHandlePlayQueueAdd = () => {
const { t } = useTranslation(); // const { t } = useTranslation();
const queryClient = useQueryClient(); // const queryClient = useQueryClient();
const playbackType = usePlaybackType(); // const playbackType = usePlaybackType();
const server = useCurrentServer(); // const server = useCurrentServer();
const { play } = usePlayerControls(); // const { play } = usePlayerControls();
const timeoutIds = useRef<null | Record<string, ReturnType<typeof setTimeout>>>({}); // const timeoutIds = useRef<null | Record<string, ReturnType<typeof setTimeout>>>({});
const { doubleClickQueueAll } = useGeneralSettings(); // const { doubleClickQueueAll } = useGeneralSettings();
const handlePlayQueueAdd = useCallback( // const handlePlayQueueAdd = useCallback(
async (options: PlayQueueAddOptions) => { // async (options: PlayQueueAddOptions) => {
if (!server) return toast.error({ message: 'No server selected', type: 'error' }); // if (!server) return toast.error({ message: 'No server selected', type: 'error' });
const { byData, byItemType, initialIndex, initialSongId, playType, query } = options; // const { byData, byItemType, initialIndex, initialSongId, playType, query } = options;
let songs: null | QueueSong[] = null; // let songs: null | QueueSong[] = null;
// Allow this to be undefined for "play shuffled". If undefined, default to 0, // // Allow this to be undefined for "play shuffled". If undefined, default to 0,
// otherwise, choose the selected item in the queue // // otherwise, choose the selected item in the queue
let initialSongIndex: number | undefined; // let initialSongIndex: number | undefined;
let toastId: null | string = null; // let toastId: null | string = null;
if (byItemType) { // if (byItemType) {
let songList: SongListResponse | undefined; // let songList: SongListResponse | undefined;
const { id, type: itemType } = byItemType; // const { id, type: itemType } = byItemType;
const fetchId = nanoid(); // const fetchId = nanoid();
timeoutIds.current = { // timeoutIds.current = {
...timeoutIds.current, // ...timeoutIds.current,
[fetchId]: setTimeout(() => { // [fetchId]: setTimeout(() => {
toastId = toast.info({ // toastId = toast.info({
autoClose: false, // autoClose: false,
message: t('player.playbackFetchCancel', { // message: t('player.playbackFetchCancel', {
postProcess: 'sentenceCase', // postProcess: 'sentenceCase',
}), // }),
onClose: () => { // onClose: () => {
queryClient.cancelQueries({ // queryClient.cancelQueries({
exact: false, // exact: false,
queryKey: getRootQueryKey(itemType, server?.id), // queryKey: getRootQueryKey(itemType, server?.id),
}); // });
}, // },
title: t('player.playbackFetchInProgress', { // title: t('player.playbackFetchInProgress', {
postProcess: 'sentenceCase', // postProcess: 'sentenceCase',
}), // }),
}); // });
}, 2000), // }, 2000),
}; // };
try { // try {
if (itemType === LibraryItem.PLAYLIST) { // if (itemType === LibraryItem.PLAYLIST) {
songList = await getPlaylistSongsById({ // songList = await getPlaylistSongsById({
id: id?.[0], // id: id?.[0],
query, // query,
queryClient, // queryClient,
server, // server,
}); // });
} else if (itemType === LibraryItem.ALBUM) { // } else if (itemType === LibraryItem.ALBUM) {
songList = await getAlbumSongsById({ id, query, queryClient, server }); // songList = await getAlbumSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.ALBUM_ARTIST) { // } else if (itemType === LibraryItem.ALBUM_ARTIST) {
songList = await getAlbumArtistSongsById({ // songList = await getAlbumArtistSongsById({
id, // id,
query, // query,
queryClient, // queryClient,
server, // server,
}); // });
} else if (itemType === LibraryItem.ARTIST) { // } else if (itemType === LibraryItem.ARTIST) {
songList = await getArtistSongsById({ // songList = await getArtistSongsById({
id, // id,
query, // query,
queryClient, // queryClient,
server, // server,
}); // });
} else if (itemType === LibraryItem.GENRE) { // } else if (itemType === LibraryItem.GENRE) {
songList = await getGenreSongsById({ id, query, queryClient, server }); // songList = await getGenreSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.SONG) { // } else if (itemType === LibraryItem.SONG) {
if (id?.length === 1) { // if (id?.length === 1) {
songList = await getSongById({ id: id?.[0], queryClient, server }); // songList = await getSongById({ id: id?.[0], queryClient, server });
} else if (!doubleClickQueueAll && initialSongId) { // } else if (!doubleClickQueueAll && initialSongId) {
songList = await getSongById({ // songList = await getSongById({
id: initialSongId, // id: initialSongId,
queryClient, // queryClient,
server, // server,
}); // });
} else { // } else {
songList = await getSongsByQuery({ query, queryClient, server }); // songList = await getSongsByQuery({ query, queryClient, server });
} // }
} // }
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>); // clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId]; // delete timeoutIds.current[fetchId];
if (toastId) { // if (toastId) {
toast.hide(toastId); // toast.hide(toastId);
} // }
} catch (err: any) { // } catch (err: any) {
if (instanceOfCancellationError(err)) { // if (instanceOfCancellationError(err)) {
return null; // return null;
} // }
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>); // clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId]; // delete timeoutIds.current[fetchId];
toast.hide(fetchId); // toast.hide(fetchId);
return toast.error({ // return toast.error({
message: err.message, // message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }) as string, // title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
}); // });
} // }
songs = // songs =
songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null; // songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null;
} else if (byData) { // } else if (byData) {
songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null; // songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null;
} // }
if (!songs || songs?.length === 0) // if (!songs || songs?.length === 0)
return toast.warn({ // return toast.warn({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }), // message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
title: t('player.playbackFetchNoResults', { postProcess: 'sentenceCase' }), // title: t('player.playbackFetchNoResults', { postProcess: 'sentenceCase' }),
}); // });
if (initialIndex) { // if (initialIndex) {
initialSongIndex = initialIndex; // initialSongIndex = initialIndex;
} else if (initialSongId) { // } else if (initialSongId) {
initialSongIndex = songs.findIndex((song) => song.id === initialSongId); // initialSongIndex = songs.findIndex((song) => song.id === initialSongId);
} // }
const hadSong = usePlayerStore.getState().queue.default.length > 0; // const hadSong = usePlayerStore.getState().queue.default.length > 0;
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs }); // const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
updateSong(playerData.current.song); // updateSong(playerData.current.song);
const replacesQueue = playType === Play.NOW || playType === Play.SHUFFLE; // const replacesQueue = playType === Play.NOW || playType === Play.SHUFFLE;
if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(usePlayerStore.getState().volume); // mpvPlayer!.volume(usePlayerStore.getState().volume);
if (replacesQueue || !hadSong) { // if (replacesQueue || !hadSong) {
mpvPlayer!.pause(); // mpvPlayer!.pause();
setQueue(playerData, false); // setQueue(playerData, false);
} else { // } else {
setQueueNext(playerData); // setQueueNext(playerData);
} // }
} else { // } else {
const player = // const player =
playerData.current.player === 1 // playerData.current.player === 1
? PlayersRef.current?.player1 // ? PlayersRef.current?.player1
: PlayersRef.current?.player2; // : PlayersRef.current?.player2;
const underlying = player?.getInternalPlayer(); // const underlying = player?.getInternalPlayer();
if (underlying && replacesQueue) { // if (underlying && replacesQueue) {
underlying.currentTime = 0; // underlying.currentTime = 0;
} // }
} // }
// We should only play if the queue was empty, or we are doing play NOW // // We should only play if the queue was empty, or we are doing play NOW
// (override the queue). // // (override the queue).
if (replacesQueue || !hadSong) { // if (replacesQueue || !hadSong) {
play(); // play();
} // }
return null; // return null;
}, // },
[doubleClickQueueAll, play, playbackType, queryClient, server, t], // [doubleClickQueueAll, play, playbackType, queryClient, server, t],
); // );
return handlePlayQueueAdd; // return handlePlayQueueAdd;
}; // };
@@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import {
useCurrentSong, usePlayerSong,
useCurrentStatus, usePlayerStatus,
usePlaybackSettings, usePlaybackSettings,
useSettingsStore, useSettingsStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
@@ -28,8 +28,8 @@ export const useMediaSession = ({
handleStop: () => void; handleStop: () => void;
}) => { }) => {
const { mediaSession: mediaSessionEnabled } = usePlaybackSettings(); const { mediaSession: mediaSessionEnabled } = usePlaybackSettings();
const playerStatus = useCurrentStatus(); const playerStatus = usePlayerStatus();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const mediaSession = navigator.mediaSession; const mediaSession = navigator.mediaSession;
const skip = useSettingsStore((state) => state.general.skipButtons); const skip = useSettingsStore((state) => state.general.skipButtons);
@@ -1,14 +1,14 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useCurrentStatus } from '/@/renderer/store'; import { usePlayerStatus } from '/@/renderer/store';
import { useWindowSettings } from '/@/renderer/store'; import { useWindowSettings } from '/@/renderer/store';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
export const usePowerSaveBlocker = () => { export const usePowerSaveBlocker = () => {
const status = useCurrentStatus(); const status = usePlayerStatus();
const { preventSleepOnPlayback } = useWindowSettings(); const { preventSleepOnPlayback } = useWindowSettings();
const startPowerSaveBlocker = useCallback(async () => { const startPowerSaveBlocker = useCallback(async () => {
@@ -1,13 +1,7 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect, WheelEvent } from 'react'; import { useCallback, useEffect, WheelEvent } from 'react';
import { import { usePlayerMuted, usePlayerSpeed, usePlayerVolume } from '/@/renderer/store';
useMuted,
usePlayerControls,
useSetCurrentSpeed,
useSpeed,
useVolume,
} from '/@/renderer/store';
import { useGeneralSettings } from '/@/renderer/store/settings.store'; import { useGeneralSettings } from '/@/renderer/store/settings.store';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
@@ -40,12 +34,12 @@ const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
}; };
export const useRightControls = () => { export const useRightControls = () => {
const { setMuted, setVolume } = usePlayerControls(); // const { setMuted, setVolume } = usePlayerControls();
const volume = useVolume(); const volume = usePlayerVolume();
const muted = useMuted(); const muted = usePlayerMuted();
const { volumeWheelStep } = useGeneralSettings(); const { volumeWheelStep } = useGeneralSettings();
const speed = useSpeed(); const speed = usePlayerSpeed();
const setCurrentSpeed = useSetCurrentSpeed(); // const setCurrentSpeed = useSetCurrentSpeed();
// Ensure that the mpv player volume is set on startup // Ensure that the mpv player volume is set on startup
useEffect(() => { useEffect(() => {
@@ -63,40 +57,37 @@ export const useRightControls = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleSpeed = useCallback( const handleSpeed = useCallback((e: number) => {
(e: number) => { // setCurrentSpeed(e);
setCurrentSpeed(e); if (mpvPlayer) {
if (mpvPlayer) { mpvPlayer?.setProperties({ speed: e });
mpvPlayer?.setProperties({ speed: e }); }
} }, []);
},
[setCurrentSpeed],
);
const handleVolumeSlider = (e: number) => { const handleVolumeSlider = (e: number) => {
mpvPlayer?.volume(e); mpvPlayer?.volume(e);
remote?.updateVolume(e); remote?.updateVolume(e);
setVolume(e); // setVolume(e);
}; };
const handleVolumeSliderState = (e: number) => { const handleVolumeSliderState = (e: number) => {
remote?.updateVolume(e); remote?.updateVolume(e);
setVolume(e); // setVolume(e);
}; };
const handleVolumeDown = useCallback(() => { const handleVolumeDown = useCallback(() => {
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep); const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet); mpvPlayer?.volume(volumeToSet);
remote?.updateVolume(volumeToSet); remote?.updateVolume(volumeToSet);
setVolume(volumeToSet); // setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]); }, [volume, volumeWheelStep]);
const handleVolumeUp = useCallback(() => { const handleVolumeUp = useCallback(() => {
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep); const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet); mpvPlayer?.volume(volumeToSet);
remote?.updateVolume(volumeToSet); remote?.updateVolume(volumeToSet);
setVolume(volumeToSet); // setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]); }, [volume, volumeWheelStep]);
const handleVolumeWheel = useCallback( const handleVolumeWheel = useCallback(
(e: WheelEvent<HTMLButtonElement | HTMLDivElement>) => { (e: WheelEvent<HTMLButtonElement | HTMLDivElement>) => {
@@ -109,15 +100,15 @@ export const useRightControls = () => {
mpvPlayer?.volume(volumeToSet); mpvPlayer?.volume(volumeToSet);
remote?.updateVolume(volumeToSet); remote?.updateVolume(volumeToSet);
setVolume(volumeToSet); // setVolume(volumeToSet);
}, },
[setVolume, volume, volumeWheelStep], [volume, volumeWheelStep],
); );
const handleMute = useCallback(() => { const handleMute = useCallback(() => {
setMuted(!muted); // setMuted(!muted);
mpvPlayer?.mute(!muted); mpvPlayer?.mute(!muted);
}, [muted, setMuted]); }, [muted]);
useEffect(() => { useEffect(() => {
if (isElectron()) { if (isElectron()) {
@@ -103,7 +103,7 @@ export const useScrobble = () => {
// Only trigger if the song changed, or the player changed. This should be the case // Only trigger if the song changed, or the player changed. This should be the case
// anyways, but who knows // anyways, but who knows
if ( if (
currentSong.uniqueId !== previous[0]?.uniqueId || currentSong._uniqueId !== previous[0]?._uniqueId ||
current[2] !== previous[2] current[2] !== previous[2]
) { ) {
const artists = const artists =
@@ -141,15 +141,15 @@ export const useScrobble = () => {
if ( if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) || (!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?.serverType === ServerType.JELLYFIN previousSong?._serverType === ServerType.JELLYFIN
) { ) {
const position = const position =
previousSong?.serverType === ServerType.JELLYFIN previousSong?._serverType === ServerType.JELLYFIN
? previousSongTimeSec * 1e7 ? previousSongTimeSec * 1e7
: undefined; : undefined;
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: previousSong?.serverId || '' }, apiClientProps: { serverId: previousSong?._serverId || '' },
query: { query: {
id: previousSong.id, id: previousSong.id,
position, position,
@@ -173,7 +173,7 @@ export const useScrobble = () => {
// Send start scrobble when song changes and the new song is playing // Send start scrobble when song changes and the new song is playing
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) { if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({ sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' }, apiClientProps: { serverId: currentSong?._serverId || '' },
query: { query: {
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
@@ -182,7 +182,7 @@ export const useScrobble = () => {
}, },
}); });
if (currentSong?.serverType === ServerType.JELLYFIN) { if (currentSong?._serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval. // It is possible that another function sets an interval.
// We only want one running, so clear the existing interval // We only want one running, so clear the existing interval
if (progressIntervalId.current) { if (progressIntervalId.current) {
@@ -379,7 +379,7 @@ export const useScrobble = () => {
// multiple times in a row and playback goes normally (no next/previous) // multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) => equalityFn: (a, b) =>
// compute whether the song changed // compute whether the song changed
a[0]?.uniqueId === b[0]?.uniqueId && a[0]?._uniqueId === b[0]?._uniqueId &&
// compute whether the same player: relevant for repeat one and repeat all (one track) // compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2], a[2] === b[2],
}, },
@@ -3,12 +3,11 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { MutationOptions } from '/@/renderer/lib/react-query'; import { MutationOptions } from '/@/renderer/lib/react-query';
import { useIncrementQueuePlayCount } from '/@/renderer/store';
import { usePlayEvent } from '/@/renderer/store/event.store'; import { usePlayEvent } from '/@/renderer/store/event.store';
import { ScrobbleArgs, ScrobbleResponse } from '/@/shared/types/domain-types'; import { ScrobbleArgs, ScrobbleResponse } from '/@/shared/types/domain-types';
export const useSendScrobble = (options?: MutationOptions) => { export const useSendScrobble = (options?: MutationOptions) => {
const incrementPlayCount = useIncrementQueuePlayCount(); // const incrementPlayCount = useIncrementQueuePlayCount();
const sendPlayEvent = usePlayEvent(); const sendPlayEvent = usePlayEvent();
return useMutation<ScrobbleResponse, AxiosError, ScrobbleArgs, null>({ return useMutation<ScrobbleResponse, AxiosError, ScrobbleArgs, null>({
@@ -21,7 +20,7 @@ export const useSendScrobble = (options?: MutationOptions) => {
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
// Manually increment the play count for the song in the queue if scrobble was submitted // Manually increment the play count for the song in the queue if scrobble was submitted
if (variables.query.submission) { if (variables.query.submission) {
incrementPlayCount([variables.query.id]); // incrementPlayCount([variables.query.id]);
sendPlayEvent(variables.query.id); sendPlayEvent(variables.query.id);
} }
}, },
@@ -29,8 +29,8 @@ import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-a
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { import {
useCurrentServer, useCurrentServer,
useCurrentSong, usePlayerSong,
useCurrentStatus, usePlayerStatus,
usePlaylistDetailStore, usePlaylistDetailStore,
usePlaylistDetailTablePagination, usePlaylistDetailTablePagination,
useSetPlaylistDetailTable, useSetPlaylistDetailTable,
@@ -58,9 +58,9 @@ interface PlaylistDetailContentProps {
export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => { export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const status = useCurrentStatus(); const status = usePlayerStatus();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const server = useCurrentServer(); const server = useCurrentServer();
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: PlaylistSongListQueryClientSide = useMemo(() => { const filters: PlaylistSongListQueryClientSide = useMemo(() => {
@@ -377,7 +377,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
if (!detailQuery.data) return; if (!detailQuery.data) return;
deletePlaylistMutation?.mutate( deletePlaylistMutation?.mutate(
{ {
apiClientProps: { serverId: detailQuery.data.serverId }, apiClientProps: { serverId: detailQuery.data._serverId },
query: { id: detailQuery.data.id }, query: { id: detailQuery.data.id },
}, },
{ {
@@ -56,7 +56,7 @@ const PlaylistDetailSongListRoute = () => {
createPlaylistMutation.mutate( createPlaylistMutation.mutate(
{ {
apiClientProps: { serverId: detailQuery?.data?.serverId }, apiClientProps: { serverId: detailQuery?.data?._serverId },
body: { body: {
_custom: { _custom: {
navidrome: { navidrome: {
@@ -83,7 +83,7 @@ const PlaylistDetailSongListRoute = () => {
}, },
); );
deletePlaylistMutation.mutate({ deletePlaylistMutation.mutate({
apiClientProps: { serverId: detailQuery?.data?.serverId }, apiClientProps: { serverId: detailQuery?.data?._serverId },
query: { id: playlistId }, query: { id: playlistId },
}); });
}, },
@@ -124,7 +124,7 @@ const PlaylistDetailSongListRoute = () => {
}), }),
) )
} }
serverId={detailQuery?.data?.serverId || ''} serverId={detailQuery?.data?._serverId || ''}
/> />
), ),
title: t('common.saveAs', { postProcess: 'sentenceCase' }), title: t('common.saveAs', { postProcess: 'sentenceCase' }),
@@ -0,0 +1,152 @@
import isElectron from 'is-electron';
import { useEffect } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { usePlayerActions, useRemoteSettings } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types';
const remote = isElectron() ? window.api.remote : null;
const ipc = isElectron() ? window.api.ipc : null;
export const useRemote = () => {
const { mediaSkipForward, setTimestamp, setVolume } = usePlayerActions();
const remoteSettings = useRemoteSettings();
const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({});
// Initialize the remote
useEffect(() => {
if (!remote) {
return;
}
remote
?.updateSetting(
remoteSettings.enabled,
remoteSettings.port,
remoteSettings.username,
remoteSettings.password,
)
.catch((error) => {
toast.warn({ message: error, title: 'Failed to enable remote' });
});
// We only want to fire this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!remote) {
return;
}
remote.requestPosition((_e: unknown, data: { position: number }) => {
const newTime = data.position;
setTimestamp(newTime);
});
remote.requestSeek((_e: unknown, data: { offset: number }) => {
mediaSkipForward(data.offset);
});
remote.requestRating(
(_e: unknown, data: { id: string; rating: number; serverId: string }) => {
updateRatingMutation.mutate({
apiClientProps: { serverId: data.serverId },
query: {
item: [
{
_serverId: data.serverId,
id: data.id,
itemType: LibraryItem.SONG,
} as Song,
],
rating: data.rating,
},
});
},
);
remote.requestVolume((_e: unknown, data: { volume: number }) => {
setVolume(data.volume);
});
remote.requestFavorite(
(_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => {
const mutator = data.favorite
? addToFavoritesMutation
: removeFromFavoritesMutation;
mutator.mutate({
apiClientProps: { serverId: data.serverId },
query: {
id: [data.id],
type: LibraryItem.SONG,
},
});
},
);
return () => {
ipc?.removeAllListeners('request-position');
ipc?.removeAllListeners('request-seek');
ipc?.removeAllListeners('request-volume');
ipc?.removeAllListeners('request-favorite');
ipc?.removeAllListeners('request-rating');
};
}, [
addToFavoritesMutation,
mediaSkipForward,
removeFromFavoritesMutation,
setTimestamp,
setVolume,
updateRatingMutation,
]);
usePlayerEvents(
{
onPlayerProgress: (properties) => {
if (!remote) {
return;
}
remote.updatePosition(properties.timestamp);
},
onPlayerRepeat: (properties) => {
if (!remote) {
return;
}
remote.updateRepeat(properties.repeat);
},
onPlayerShuffle: (properties) => {
if (!remote) {
return;
}
const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE;
remote.updateShuffle(isShuffleEnabled);
},
onPlayerStatus: (properties) => {
if (!remote) {
return;
}
remote.updatePlayback(properties.status);
},
onPlayerVolume: (properties) => {
if (!remote) {
return;
}
remote.updateVolume(properties.volume);
},
},
[],
);
};
@@ -7,7 +7,7 @@ import {
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { useHotkeySettings, usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store'; import { useHotkeySettings, usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
const isWindows = isElectron() ? window.api.utils.isWindows() : false; const isWindows = isElectron() ? window.api.utils.isWindows() : false;
@@ -25,9 +25,7 @@ export const WindowHotkeySettings = () => {
defaultChecked={settings.globalMediaHotkeys} defaultChecked={settings.globalMediaHotkeys}
disabled={ disabled={
!isElectron() || !isElectron() ||
(enableWindowsMediaSession && (enableWindowsMediaSession && isWindows && playbackType === PlayerType.WEB)
isWindows &&
playbackType === PlaybackType.WEB)
} }
onChange={(e) => { onChange={(e) => {
setSettings({ setSettings({
@@ -6,14 +6,13 @@ import {
SettingOption, SettingOption,
SettingsSection, SettingsSection,
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store'; import { usePlayerActions, usePlayerProperties, usePlayerStatus } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider'; import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { CrossfadeStyle, PlaybackStyle, PlaybackType, PlayerStatus } from '/@/shared/types/types'; import { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
@@ -26,7 +25,10 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
const { t } = useTranslation(); const { t } = useTranslation();
const settings = usePlaybackSettings(); const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const status = useCurrentStatus(); const status = usePlayerStatus();
const { crossfadeDuration, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setTransitionType } = usePlayerActions();
const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]); const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]);
@@ -43,7 +45,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
); );
}; };
if (settings.type === PlaybackType.WEB) { if (settings.type === PlayerType.WEB) {
getAudioDevices(); getAudioDevices();
} }
}, [settings.type, t]); }, [settings.type, t]);
@@ -56,19 +58,15 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
{ {
disabled: !isElectron(), disabled: !isElectron(),
label: 'MPV', label: 'MPV',
value: PlaybackType.LOCAL, value: PlayerType.LOCAL,
}, },
{ label: 'Web', value: PlaybackType.WEB }, { label: 'Web', value: PlayerType.WEB },
]} ]}
defaultValue={settings.type} defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING} disabled={status === PlayerStatus.PLAYING}
onChange={(e) => { onChange={(e) => {
setSettings({ playback: { ...settings, type: e as PlaybackType } }); setSettings({ playback: { ...settings, type: e as PlayerType } });
ipc?.send('settings-set', { property: 'playbackType', value: e }); ipc?.send('settings-set', { property: 'playbackType', value: e });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
setQueue(queueData);
}
}} }}
/> />
), ),
@@ -89,7 +87,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
clearable clearable
data={audioDevices} data={audioDevices}
defaultValue={settings.audioDeviceId} defaultValue={settings.audioDeviceId}
disabled={settings.type !== PlaybackType.WEB} disabled={settings.type !== PlayerType.WEB}
onChange={(e) => setSettings({ playback: { ...settings, audioDeviceId: e } })} onChange={(e) => setSettings({ playback: { ...settings, audioDeviceId: e } })}
/> />
), ),
@@ -97,7 +95,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: !isElectron() || settings.type !== PlaybackType.WEB, isHidden: !isElectron() || settings.type !== PlayerType.WEB,
title: t('setting.audioDevice', { postProcess: 'sentenceCase' }), title: t('setting.audioDevice', { postProcess: 'sentenceCase' }),
}, },
{ {
@@ -109,28 +107,26 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'optionNormal', context: 'optionNormal',
postProcess: 'titleCase', postProcess: 'titleCase',
}), }),
value: PlaybackStyle.GAPLESS, value: PlayerStyle.GAPLESS,
}, },
{ {
label: t('setting.playbackStyle', { label: t('setting.playbackStyle', {
context: 'optionCrossFade', context: 'optionCrossFade',
postProcess: 'titleCase', postProcess: 'titleCase',
}), }),
value: PlaybackStyle.CROSSFADE, value: PlayerStyle.CROSSFADE,
}, },
]} ]}
defaultValue={settings.style} defaultValue={transitionType}
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING} disabled={settings.type !== PlayerType.WEB || status === PlayerStatus.PLAYING}
onChange={(e) => onChange={(e) => setTransitionType(e as PlayerStyle)}
setSettings({ playback: { ...settings, style: e as PlaybackStyle } })
}
/> />
), ),
description: t('setting.playbackStyle', { description: t('setting.playbackStyle', {
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.WEB, isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined, note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.playbackStyle', { title: t('setting.playbackStyle', {
context: 'description', context: 'description',
@@ -152,7 +148,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.WEB, isHidden: settings.type !== PlayerType.WEB,
note: t('common.restartRequired', { postProcess: 'sentenceCase' }), note: t('common.restartRequired', { postProcess: 'sentenceCase' }),
title: t('setting.webAudio', { title: t('setting.webAudio', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
@@ -173,7 +169,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.WEB, isHidden: settings.type !== PlayerType.WEB,
title: t('setting.preservePitch', { title: t('setting.preservePitch', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
@@ -181,17 +177,15 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
{ {
control: ( control: (
<Slider <Slider
defaultValue={settings.crossfadeDuration} defaultValue={crossfadeDuration}
disabled={ disabled={
settings.type !== PlaybackType.WEB || settings.type !== PlayerType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE || settings.style !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING status === PlayerStatus.PLAYING
} }
max={15} max={15}
min={0} min={3}
onChangeEnd={(e) => onChangeEnd={(e) => setCrossfadeDuration(e)}
setSettings({ playback: { ...settings, crossfadeDuration: e } })
}
w={100} w={100}
/> />
), ),
@@ -199,7 +193,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.WEB, isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined, note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.crossfadeDuration', { title: t('setting.crossfadeDuration', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
@@ -224,8 +218,8 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
]} ]}
defaultValue={settings.crossfadeStyle} defaultValue={settings.crossfadeStyle}
disabled={ disabled={
settings.type !== PlaybackType.WEB || settings.type !== PlayerType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE || settings.style !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING status === PlayerStatus.PLAYING
} }
onChange={(e) => { onChange={(e) => {
@@ -241,7 +235,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.WEB, isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined, note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.crossfadeStyle', { postProcess: 'sentenceCase' }), title: t('setting.crossfadeStyle', { postProcess: 'sentenceCase' }),
}, },
@@ -7,7 +7,7 @@ import {
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const isWindows = isElectron() ? window.api.utils.isWindows() : null; const isWindows = isElectron() ? window.api.utils.isWindows() : null;
const isDesktop = isElectron(); const isDesktop = isElectron();
@@ -30,7 +30,7 @@ export const MediaSessionSettings = () => {
<Switch <Switch
aria-label="Toggle media Session" aria-label="Toggle media Session"
defaultChecked={mediaSession} defaultChecked={mediaSession}
disabled={!isWindows || !isDesktop || playbackType !== PlaybackType.WEB} disabled={!isWindows || !isDesktop || playbackType !== PlayerType.WEB}
onChange={handleMediaSessionChange} onChange={handleMediaSessionChange}
/> />
), ),
@@ -6,11 +6,9 @@ import {
SettingOption, SettingOption,
SettingsSection, SettingsSection,
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { usePlayerControls, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { import {
SettingsState, SettingsState,
usePlaybackSettings, usePlaybackSettings,
useSettingsStore,
useSettingsStoreActions, useSettingsStoreActions,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
@@ -22,7 +20,7 @@ import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Textarea } from '/@/shared/components/textarea/textarea'; import { Textarea } from '/@/shared/components/textarea/textarea';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
@@ -74,8 +72,8 @@ export const MpvSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const settings = usePlaybackSettings(); const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const { pause } = usePlayerControls(); // const { pause } = usePlayerControls();
const { clearQueue } = useQueueControls(); // const { clearQueue } = useQueueControls();
const [mpvPath, setMpvPath] = useState( const [mpvPath, setMpvPath] = useState(
(localSettings?.get('mpv_path') as string | undefined) || '', (localSettings?.get('mpv_path') as string | undefined) || '',
@@ -129,21 +127,21 @@ export const MpvSettings = () => {
mpvPlayer?.setProperties(mpvSetting); mpvPlayer?.setProperties(mpvSetting);
}; };
const handleReloadMpv = () => { // const handleReloadMpv = () => {
pause(); // pause();
clearQueue(); // clearQueue();
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters; // const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties: Record<string, any> = { // const properties: Record<string, any> = {
speed: usePlayerStore.getState().speed, // speed: usePlayerStore.getState().speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties), // ...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
}; // };
mpvPlayer?.restart({ // mpvPlayer?.restart({
binaryPath: mpvPath || undefined, // binaryPath: mpvPath || undefined,
extraParameters, // extraParameters,
properties, // properties,
}); // });
}; // };
const handleSetExtraParameters = (data: string[]) => { const handleSetExtraParameters = (data: string[]) => {
setSettings({ setSettings({
@@ -160,7 +158,7 @@ export const MpvSettings = () => {
<Group gap="sm"> <Group gap="sm">
<ActionIcon <ActionIcon
icon="refresh" icon="refresh"
onClick={handleReloadMpv} // onClick={handleReloadMpv}
tooltip={{ tooltip={{
label: t('common.reload', { postProcess: 'titleCase' }), label: t('common.reload', { postProcess: 'titleCase' }),
openDelay: 0, openDelay: 0,
@@ -194,7 +192,7 @@ export const MpvSettings = () => {
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.LOCAL, isHidden: settings.type !== PlayerType.LOCAL,
note: 'Restart required', note: 'Restart required',
title: t('setting.mpvExecutablePath', { postProcess: 'sentenceCase' }), title: t('setting.mpvExecutablePath', { postProcess: 'sentenceCase' }),
}, },
@@ -235,7 +233,7 @@ export const MpvSettings = () => {
</Text> </Text>
</Stack> </Stack>
), ),
isHidden: settings.type !== PlaybackType.LOCAL, isHidden: settings.type !== PlayerType.LOCAL,
note: t('common.restartRequired', { note: t('common.restartRequired', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
@@ -268,7 +266,7 @@ export const MpvSettings = () => {
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.LOCAL, isHidden: settings.type !== PlayerType.LOCAL,
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }), title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),
}, },
{ {
@@ -311,7 +309,7 @@ export const MpvSettings = () => {
context: 'description', context: 'description',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
isHidden: settings.type !== PlaybackType.LOCAL, isHidden: settings.type !== PlayerType.LOCAL,
title: t('setting.audioExclusiveMode', { postProcess: 'sentenceCase' }), title: t('setting.audioExclusiveMode', { postProcess: 'sentenceCase' }),
}, },
]; ];
@@ -8,7 +8,7 @@ import { ScrobbleSettings } from '/@/renderer/features/settings/components/playb
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings'; import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
import { useSettingsStore } from '/@/renderer/store'; import { useSettingsStore } from '/@/renderer/store';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { PlaybackType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
const MpvSettings = lazy(() => const MpvSettings = lazy(() =>
import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => { import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => {
@@ -22,7 +22,7 @@ export const PlaybackTab = () => {
const hasFancyAudio = useMemo(() => { const hasFancyAudio = useMemo(() => {
return ( return (
(isElectron() && audioType === PlaybackType.LOCAL) || (isElectron() && audioType === PlayerType.LOCAL) ||
(useWebAudio && 'AudioContext' in window) (useWebAudio && 'AudioContext' in window)
); );
}, [audioType, useWebAudio]); }, [audioType, useWebAudio]);
@@ -5,7 +5,7 @@ import isElectron from 'is-electron';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import { useSetAlbumListItemDataById } from '/@/renderer/store';
import { useFavoriteEvent } from '/@/renderer/store/event.store'; import { useFavoriteEvent } from '/@/renderer/store/event.store';
import { import {
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
@@ -21,7 +21,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => {
const { options } = args || {}; const { options } = args || {};
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setAlbumListData = useSetAlbumListItemDataById(); const setAlbumListData = useSetAlbumListItemDataById();
const setQueueFavorite = useSetQueueFavorite(); // const setQueueFavorite = useSetQueueFavorite();
const setFavoriteEvent = useFavoriteEvent(); const setFavoriteEvent = useFavoriteEvent();
return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, null>({ return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, null>({
@@ -46,7 +46,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => {
if (variables.query.type === LibraryItem.SONG) { if (variables.query.type === LibraryItem.SONG) {
remote?.updateFavorite(true, serverId, variables.query.id); remote?.updateFavorite(true, serverId, variables.query.id);
setQueueFavorite(variables.query.id, true); // setQueueFavorite(variables.query.id, true);
setFavoriteEvent(variables.query.id, true); setFavoriteEvent(variables.query.id, true);
} }
@@ -5,7 +5,7 @@ import isElectron from 'is-electron';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import { useSetAlbumListItemDataById } from '/@/renderer/store';
import { useFavoriteEvent } from '/@/renderer/store/event.store'; import { useFavoriteEvent } from '/@/renderer/store/event.store';
import { import {
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
@@ -21,7 +21,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => {
const { options } = args || {}; const { options } = args || {};
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setAlbumListData = useSetAlbumListItemDataById(); const setAlbumListData = useSetAlbumListItemDataById();
const setQueueFavorite = useSetQueueFavorite(); // const setQueueFavorite = useSetQueueFavorite();
const setFavoriteEvent = useFavoriteEvent(); const setFavoriteEvent = useFavoriteEvent();
return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, null>({ return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, null>({
@@ -44,7 +44,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => {
if (variables.query.type === LibraryItem.SONG) { if (variables.query.type === LibraryItem.SONG) {
remote?.updateFavorite(false, serverId, variables.query.id); remote?.updateFavorite(false, serverId, variables.query.id);
setQueueFavorite(variables.query.id, false); // setQueueFavorite(variables.query.id, false);
setFavoriteEvent(variables.query.id, false); setFavoriteEvent(variables.query.id, false);
} }
@@ -5,7 +5,7 @@ import isElectron from 'is-electron';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store'; import { useSetAlbumListItemDataById } from '/@/renderer/store';
import { useRatingEvent } from '/@/renderer/store/event.store'; import { useRatingEvent } from '/@/renderer/store/event.store';
import { import {
Album, Album,
@@ -24,7 +24,7 @@ export const useSetRating = (args: MutationHookArgs) => {
const { options } = args || {}; const { options } = args || {};
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setAlbumListData = useSetAlbumListItemDataById(); const setAlbumListData = useSetAlbumListItemDataById();
const setQueueRating = useSetQueueRating(); // const setQueueRating = useSetQueueRating();
const setRatingEvent = useRatingEvent(); const setRatingEvent = useRatingEvent();
return useMutation< return useMutation<
@@ -46,7 +46,7 @@ export const useSetRating = (args: MutationHookArgs) => {
setAlbumListData(item.id, { userRating: item.userRating }); setAlbumListData(item.id, { userRating: item.userRating });
break; break;
case LibraryItem.SONG: case LibraryItem.SONG:
setQueueRating([item.id], item.userRating); // setQueueRating([item.id], item.userRating);
setRatingEvent([item.id], item.userRating); setRatingEvent([item.id], item.userRating);
break; break;
} }
@@ -67,14 +67,14 @@ export const useSetRating = (args: MutationHookArgs) => {
} }
if (songIds.length > 0) { if (songIds.length > 0) {
setQueueRating(songIds, variables.query.rating); // setQueueRating(songIds, variables.query.rating);
setRatingEvent(songIds, variables.query.rating); setRatingEvent(songIds, variables.query.rating);
} }
if (remote) { if (remote) {
remote.updateRating( remote.updateRating(
variables.query.rating, variables.query.rating,
variables.query.item[0].serverId, variables.query.item[0]._serverId,
songIds, songIds,
); );
} }
@@ -88,9 +88,9 @@ export const useSetRating = (args: MutationHookArgs) => {
variables.query.item[0].itemType === LibraryItem.ALBUM; variables.query.item[0].itemType === LibraryItem.ALBUM;
if (isAlbumDetailPage) { if (isAlbumDetailPage) {
const { id: albumId, serverId } = variables.query.item[0] as Album; const { id: albumId, _serverId } = variables.query.item[0] as Album;
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId }); const queryKey = queryKeys.albums.detail(_serverId || '', { id: albumId });
const previous = queryClient.getQueryData<AlbumDetailResponse>(queryKey); const previous = queryClient.getQueryData<AlbumDetailResponse>(queryKey);
if (previous) { if (previous) {
queryClient.setQueryData<AlbumDetailResponse>(queryKey, { queryClient.setQueryData<AlbumDetailResponse>(queryKey, {
@@ -17,7 +17,7 @@ import {
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list'; } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
import { import {
useAppStoreActions, useAppStoreActions,
useCurrentSong, usePlayerSong,
useFullScreenPlayerStore, useFullScreenPlayerStore,
useSetFullScreenPlayerStore, useSetFullScreenPlayerStore,
useSidebarStore, useSidebarStore,
@@ -42,7 +42,7 @@ export const Sidebar = () => {
const sidebar = useSidebarStore(); const sidebar = useSidebarStore();
const { setSideBar } = useAppStoreActions(); const { setSideBar } = useAppStoreActions();
const { sidebarPlaylistList } = useGeneralSettings(); const { sidebarPlaylistList } = useGeneralSettings();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const translatedSidebarItemMap = useMemo( const translatedSidebarItemMap = useMemo(
() => ({ () => ({
@@ -9,7 +9,6 @@ import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-tabl
import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback'; import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store'; import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
@@ -24,8 +23,6 @@ export type SimilarSongsListProps = {
export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListProps) => { export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListProps) => {
const tableRef = useRef<AgGridReact<Song> | null>(null); const tableRef = useRef<AgGridReact<Song> | null>(null);
const tableConfig = useTableSettings(fullScreen ? 'fullScreen' : 'songs'); const tableConfig = useTableSettings(fullScreen ? 'fullScreen' : 'songs');
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const songQuery = useQuery( const songQuery = useQuery(
songsQueries.similar({ songsQueries.similar({
@@ -37,7 +34,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr
count, count,
songId: song.id, songId: song.id,
}, },
serverId: song?.serverId, serverId: song?._serverId,
}), }),
); );
@@ -51,11 +48,11 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr
const handleRowDoubleClick = (e: RowDoubleClickedEvent<Song>) => { const handleRowDoubleClick = (e: RowDoubleClickedEvent<Song>) => {
if (!e.data || !songQuery.data) return; if (!e.data || !songQuery.data) return;
handlePlayQueueAdd?.({ // handlePlayQueueAdd?.({
byData: songQuery.data, // byData: songQuery.data,
initialSongId: e.data.id, // initialSongId: e.data.id,
playType: playButtonBehavior, // playType: playButtonBehavior,
}); // });
}; };
return songQuery.isLoading ? ( return songQuery.isLoading ? (
@@ -12,8 +12,8 @@ import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/conte
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { import {
useCurrentServer, useCurrentServer,
useCurrentSong, usePlayerSong,
useCurrentStatus, usePlayerStatus,
usePlayButtonBehavior, usePlayButtonBehavior,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LibraryItem, QueueSong, SongListQuery } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, SongListQuery } from '/@/shared/types/domain-types';
@@ -27,8 +27,8 @@ export const SongListTableView = ({ itemCount, tableRef }: SongListTableViewProp
const server = useCurrentServer(); const server = useCurrentServer();
const { customFilters, handlePlay, id, pageKey } = useListContext(); const { customFilters, handlePlay, id, pageKey } = useListContext();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const currentSong = useCurrentSong(); const currentSong = usePlayerSong();
const status = useCurrentStatus(); const status = usePlayerStatus();
const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
+7 -29
View File
@@ -1,22 +1,16 @@
import { HotkeyItem, useHotkeys } from '@mantine/hooks'; import { HotkeyItem, useHotkeys } from '@mantine/hooks';
import clsx from 'clsx'; import clsx from 'clsx';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { lazy, useMemo } from 'react'; import { lazy } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import styles from './default-layout.module.css'; import styles from './default-layout.module.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu/context-menu-provider';
import { CommandPalette } from '/@/renderer/features/search/components/command-palette'; import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
import { MainContent } from '/@/renderer/layouts/default-layout/main-content'; import { MainContent } from '/@/renderer/layouts/default-layout/main-content';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar'; import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import { useCommandPalette } from '/@/renderer/store';
useAppStore,
useCommandPalette,
useCurrentStatus,
useQueueStatus,
} from '/@/renderer/store';
import { import {
useGeneralSettings, useGeneralSettings,
useHotkeySettings, useHotkeySettings,
@@ -24,13 +18,13 @@ import {
useSettingsStoreActions, useSettingsStoreActions,
useWindowSettings, useWindowSettings,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { Platform, PlaybackType, PlayerStatus } from '/@/shared/types/types'; import { Platform, PlayerType } from '/@/shared/types/types';
if (!isElectron()) { if (!isElectron()) {
useSettingsStore.getState().actions.setSettings({ useSettingsStore.getState().actions.setSettings({
playback: { playback: {
...useSettingsStore.getState().playback, ...useSettingsStore.getState().playback,
type: PlaybackType.WEB, type: PlayerType.WEB,
}, },
}); });
} }
@@ -53,9 +47,6 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
const settings = useGeneralSettings(); const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const playerStatus = useCurrentStatus();
const { currentSong, index, length } = useQueueStatus();
const { privateMode } = useAppStore();
const updateZoom = (increase: number) => { const updateZoom = (increase: number) => {
const newVal = settings.zoomFactor + increase; const newVal = settings.zoomFactor + increase;
@@ -83,21 +74,8 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
...(isElectron() ? zoomHotkeys : []), ...(isElectron() ? zoomHotkeys : []),
]); ]);
const title = useMemo(() => {
const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : '';
const queueString = length ? `(${index + 1} / ${length}) ` : '';
const privateModeString = privateMode ? '(Private mode)' : '';
const title = `${
length
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? `${currentSong?.artistName} — Feishin` : ''}`
: 'Feishin'
}${privateMode ? ` ${privateModeString}` : ''}`;
document.title = title;
return title;
}, [currentSong?.artistName, currentSong?.name, index, length, playerStatus, privateMode]);
return ( return (
<ContextMenuProvider> <>
<div <div
className={clsx(styles.layout, { className={clsx(styles.layout, {
[styles.macos]: windowBarStyle === Platform.MACOS, [styles.macos]: windowBarStyle === Platform.MACOS,
@@ -105,11 +83,11 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
})} })}
id="default-layout" id="default-layout"
> >
{windowBarStyle !== Platform.WEB && <WindowBar title={title} />} <WindowBar />
<MainContent shell={shell} /> <MainContent shell={shell} />
<PlayerBar /> <PlayerBar />
</div> </div>
<CommandPalette modalProps={{ handlers, opened }} /> <CommandPalette modalProps={{ handlers, opened }} />
</ContextMenuProvider> </>
); );
}; };
+24 -8
View File
@@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri'; import { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri';
import appIcon from '../../../assets/icons/32x32.png'; import appIcon from '../../../assets/icons/32x32.png';
@@ -12,9 +12,9 @@ import macMinHover from './assets/min-mac-hover.png';
import macMin from './assets/min-mac.png'; import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css'; import styles from './window-bar.module.css';
import { useWindowSettings } from '/@/renderer/store'; import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Platform } from '/@/shared/types/types'; import { Platform, PlayerStatus } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
@@ -121,14 +121,13 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
); );
}; };
interface WindowBarProps { export const WindowBar = () => {
title: string;
}
export const WindowBar = ({ title }: WindowBarProps) => {
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const playerStatus = usePlayerStatus();
const { privateMode } = useAppStore();
const handleMinimize = () => minimize(); const handleMinimize = () => minimize();
const { currentSong, index, queueLength } = usePlayerData();
const [max, setMax] = useState(localSettings?.env.START_MAXIMIZED || false); const [max, setMax] = useState(localSettings?.env.START_MAXIMIZED || false);
const handleMaximize = useCallback(() => { const handleMaximize = useCallback(() => {
@@ -142,6 +141,23 @@ export const WindowBar = ({ title }: WindowBarProps) => {
const handleClose = useCallback(() => close(), []); const handleClose = useCallback(() => close(), []);
const title = useMemo(() => {
const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : '';
const queueString = queueLength ? `(${index + 1} / ${queueLength}) ` : '';
const privateModeString = privateMode ? '(Private mode)' : '';
const title = `${
queueLength
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? `${currentSong?.artistName} — Feishin` : ''}`
: 'Feishin'
}${privateMode ? ` ${privateModeString}` : ''}`;
document.title = title;
return title;
}, [currentSong?.artistName, currentSong?.name, index, playerStatus, privateMode, queueLength]);
if (windowBarStyle === Platform.WEB) {
return null;
}
return ( return (
<> <>
{windowBarStyle === Platform.WINDOWS && ( {windowBarStyle === Platform.WINDOWS && (
+15
View File
@@ -0,0 +1,15 @@
import type { StoreApi, UseBoundStore } from 'zustand';
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};
+14 -14
View File
@@ -4,7 +4,7 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated'; import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useSetPlayerFallback } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
@@ -16,7 +16,7 @@ const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;
export const AppOutlet = () => { export const AppOutlet = () => {
const currentServer = useCurrentServer(); const currentServer = useCurrentServer();
const setFallback = useSetPlayerFallback(); // const setFallback = useSetPlayerFallback();
const authState = useServerAuthenticated(); const authState = useServerAuthenticated();
const isActionsRequired = useMemo(() => { const isActionsRequired = useMemo(() => {
@@ -28,20 +28,20 @@ export const AppOutlet = () => {
return isActionRequired; return isActionRequired;
}, [currentServer]); }, [currentServer]);
useEffect(() => { // useEffect(() => {
utils?.mainMessageListener((_event, data) => { // utils?.mainMessageListener((_event, data) => {
toast.show(data); // toast.show(data);
}); // });
mpvPlayerListener?.rendererPlayerFallback((_event, data) => { // mpvPlayerListener?.rendererPlayerFallback((_event, data) => {
setFallback(data); // setFallback(data);
}); // });
return () => { // return () => {
ipc?.removeAllListeners('toast-from-main'); // ipc?.removeAllListeners('toast-from-main');
ipc?.removeAllListeners('renderer-player-fallback'); // ipc?.removeAllListeners('renderer-player-fallback');
}; // };
}, [setFallback]); // }, [setFallback]);
if (authState === AuthState.LOADING) { if (authState === AuthState.LOADING) {
return ( return (
File diff suppressed because it is too large Load Diff
+7 -14
View File
@@ -9,7 +9,6 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { ContextMenuItemType } from '/@/renderer/features/context-menu/events'; import { ContextMenuItemType } from '/@/renderer/features/context-menu/events';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { usePlayerStore } from '/@/renderer/store/player.store';
import { mergeOverridingColumns } from '/@/renderer/store/utils'; import { mergeOverridingColumns } from '/@/renderer/store/utils';
import { FontValueSchema } from '/@/renderer/types/fonts'; import { FontValueSchema } from '/@/renderer/types/fonts';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
@@ -21,8 +20,8 @@ import {
FontType, FontType,
Platform, Platform,
Play, Play,
PlaybackStyle, PlayerStyle,
PlaybackType, PlayerType,
TableColumn, TableColumn,
TableType, TableType,
} from '/@/shared/types/types'; } from '/@/shared/types/types';
@@ -258,9 +257,9 @@ const PlaybackSettingsSchema = z.object({
muted: z.boolean(), muted: z.boolean(),
preservePitch: z.boolean(), preservePitch: z.boolean(),
scrobble: ScrobbleSettingsSchema, scrobble: ScrobbleSettingsSchema,
style: z.nativeEnum(PlaybackStyle), style: z.nativeEnum(PlayerStyle),
transcode: TranscodingConfigSchema, transcode: TranscodingConfigSchema,
type: z.nativeEnum(PlaybackType), type: z.nativeEnum(PlayerType),
webAudio: z.boolean(), webAudio: z.boolean(),
}); });
@@ -652,11 +651,11 @@ const initialState: SettingsState = {
scrobbleAtDuration: 240, scrobbleAtDuration: 240,
scrobbleAtPercentage: 75, scrobbleAtPercentage: 75,
}, },
style: PlaybackStyle.GAPLESS, style: PlayerStyle.GAPLESS,
transcode: { transcode: {
enabled: false, enabled: false,
}, },
type: PlaybackType.WEB, type: PlayerType.WEB,
webAudio: true, webAudio: true,
}, },
remote: { remote: {
@@ -843,7 +842,7 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
...initialState, ...initialState,
playback: { playback: {
...initialState.playback, ...initialState.playback,
type: PlaybackType.WEB, type: PlayerType.WEB,
}, },
}); });
} else { } else {
@@ -967,12 +966,6 @@ export const useGeneralSettings = () => useSettingsStore((state) => state.genera
export const usePlaybackType = () => export const usePlaybackType = () =>
useSettingsStore((state) => { useSettingsStore((state) => {
const isFallback = usePlayerStore.getState().fallback;
if (isFallback) {
return PlaybackType.WEB;
}
return state.playback.type; return state.playback.type;
}); });
@@ -12,7 +12,7 @@ const modifyUrl = (song: QueueSong): string => {
if (transcode.enabled) { if (transcode.enabled) {
const streamUrl = api.controller.getTranscodingUrl({ const streamUrl = api.controller.getTranscodingUrl({
apiClientProps: { apiClientProps: {
serverId: song.serverId, serverId: song._serverId,
}, },
query: { query: {
base: song.streamUrl, base: song.streamUrl,
+22
View File
@@ -0,0 +1,22 @@
export function shuffle<T>(array: T[]): T[] {
// Create a copy of the array to avoid mutating the original
const shuffled = [...array];
// Loop through the array from the last element to the first
for (let i = shuffled.length - 1; i > 0; i--) {
// Generate a random index from 0 to i
const j = Math.floor(Math.random() * (i + 1));
// Swap elements at positions i and j
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
export function shuffleInPlace<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
+8 -11
View File
@@ -1,4 +1,3 @@
import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
@@ -228,6 +227,8 @@ const normalizeSong = (
} }
return { return {
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
album: item.Album, album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id, id: entry.Id,
@@ -290,13 +291,10 @@ const normalizeSong = (
: null, : null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null, releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
sampleRate, sampleRate,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size, size,
streamUrl, streamUrl,
tags: getTags(item), tags: getTags(item),
trackNumber: item.IndexNumber, trackNumber: item.IndexNumber,
uniqueId: nanoid(),
updatedAt: item.DateCreated, updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false, userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null, userRating: null,
@@ -309,6 +307,8 @@ const normalizeAlbum = (
imageSize?: number, imageSize?: number,
): Album => { ): Album => {
return { return {
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
albumArtist: item.AlbumArtist, albumArtist: item.AlbumArtist,
albumArtists: albumArtists:
item.AlbumArtists.map((entry) => ({ item.AlbumArtists.map((entry) => ({
@@ -351,13 +351,10 @@ const normalizeAlbum = (
releaseDate: item.PremiereDate?.split('T')[0] || null, releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseTypes: [], releaseTypes: [],
releaseYear: item.ProductionYear || null, releaseYear: item.ProductionYear || null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)), songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
tags: getTags(item), tags: getTags(item),
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated, updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
userRating: null, userRating: null,
@@ -386,6 +383,8 @@ const normalizeAlbumArtist = (
) || []; ) || [];
return { return {
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
albumCount: item.AlbumCount ?? null, albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null, backgroundImageUrl: null,
biography: item.Overview || null, biography: item.Overview || null,
@@ -407,8 +406,6 @@ const normalizeAlbumArtist = (
mbz: item.ProviderIds?.MusicBrainzArtist || null, mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name, name: item.Name,
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists, similarArtists,
songCount: item.SongCount ?? null, songCount: item.SongCount ?? null,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
@@ -430,6 +427,8 @@ const normalizePlaylist = (
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
description: item.Overview || null, description: item.Overview || null,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ genres: item.GenreItems?.map((entry) => ({
@@ -447,8 +446,6 @@ const normalizePlaylist = (
ownerId: null, ownerId: null,
public: null, public: null,
rules: null, rules: null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
sync: null, sync: null,
@@ -1,4 +1,3 @@
import { nanoid } from 'nanoid';
import z from 'zod'; import z from 'zod';
import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { ndType } from '/@/shared/api/navidrome/navidrome-types';
@@ -152,6 +151,8 @@ const normalizeSong = (
album: item.album, album: item.album,
albumId: item.albumId, albumId: item.albumId,
...getArtists(item), ...getArtists(item),
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
artistName: item.artist, artistName: item.artist,
bitDepth: item.bitDepth || null, bitDepth: item.bitDepth || null,
bitRate: item.bitRate, bitRate: item.bitRate,
@@ -204,13 +205,10 @@ const normalizeSong = (
).toISOString(), ).toISOString(),
releaseYear: String(item.year), releaseYear: String(item.year),
sampleRate: item.sampleRate || null, sampleRate: item.sampleRate || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size, size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`, streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`,
tags: item.tags || null, tags: item.tags || null,
trackNumber: item.trackNumber, trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
userFavorite: item.starred || false, userFavorite: item.starred || false,
userRating: item.rating || null, userRating: item.rating || null,
@@ -280,6 +278,8 @@ const normalizeAlbum = (
return { return {
...parseAlbumTags(item), ...parseAlbumTags(item),
...getArtists(item), ...getArtists(item),
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumArtist: item.albumArtist, albumArtist: item.albumArtist,
backdropImageUrl: imageBackdropUrl, backdropImageUrl: imageBackdropUrl,
comment: item.comment || null, comment: item.comment || null,
@@ -301,9 +301,9 @@ const normalizeAlbum = (
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl, imageUrl,
isCompilation: item.compilation, isCompilation: item.compilation,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null, mbzId: item.mbzAlbumId || null,
name: item.name, name: item.name,
originalDate: item.originalDate originalDate: item.originalDate
@@ -317,12 +317,9 @@ const normalizeAlbum = (
: new Date(Date.UTC(item.minYear, 0, 1)) : new Date(Date.UTC(item.minYear, 0, 1))
).toISOString(), ).toISOString(),
releaseYear: item.minYear, releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size, size: item.size,
songCount: item.songCount, songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined, songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
userFavorite: item.starred, userFavorite: item.starred,
userRating: item.rating || null, userRating: item.rating || null,
@@ -365,6 +362,8 @@ const normalizeAlbumArtist = (
} }
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumCount, albumCount,
backgroundImageUrl: null, backgroundImageUrl: null,
biography: item.biography || null, biography: item.biography || null,
@@ -382,8 +381,6 @@ const normalizeAlbumArtist = (
mbz: item.mbzArtistId || null, mbz: item.mbzArtistId || null,
name: item.name, name: item.name,
playCount: item.playCount || 0, playCount: item.playCount || 0,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
similarArtists: similarArtists:
item.similarArtists?.map((artist) => ({ item.similarArtists?.map((artist) => ({
id: artist.id, id: artist.id,
@@ -412,6 +409,8 @@ const normalizePlaylist = (
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
description: item.comment, description: item.comment,
duration: item.duration * 1000, duration: item.duration * 1000,
genres: [], genres: [],
@@ -424,8 +423,6 @@ const normalizePlaylist = (
ownerId: item.ownerId, ownerId: item.ownerId,
public: item.public, public: item.public,
rules: item?.rules || null, rules: item?.rules || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size, size: item.size,
songCount: item.songCount, songCount: item.songCount,
sync: item.sync, sync: item.sync,
+10 -13
View File
@@ -1,4 +1,3 @@
import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { ssType } from '/@/shared/api/subsonic/subsonic-types';
@@ -9,10 +8,10 @@ import {
Genre, Genre,
LibraryItem, LibraryItem,
Playlist, Playlist,
QueueSong,
RelatedArtist, RelatedArtist,
ServerListItemWithCredential, ServerListItemWithCredential,
ServerType, ServerType,
Song,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@@ -119,7 +118,7 @@ const normalizeSong = (
item: z.infer<typeof ssType._response.song>, item: z.infer<typeof ssType._response.song>,
server?: null | ServerListItemWithCredential, server?: null | ServerListItemWithCredential,
size?: number, size?: number,
): QueueSong => { ): Song => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
@@ -131,6 +130,8 @@ const normalizeSong = (
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=Feishin&${server?.credential}`; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=Feishin&${server?.credential}`;
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
album: item.album || '', album: item.album || '',
albumArtists: getArtistList(item.albumArtists, item.artistId, item.artist), albumArtists: getArtistList(item.albumArtists, item.artistId, item.artist),
albumId: item.albumId?.toString() || '', albumId: item.albumId?.toString() || '',
@@ -183,13 +184,10 @@ const normalizeSong = (
releaseDate: null, releaseDate: null,
releaseYear: item.year ? String(item.year) : null, releaseYear: item.year ? String(item.year) : null,
sampleRate: item.samplingRate || null, sampleRate: item.samplingRate || null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size, size: item.size,
streamUrl, streamUrl,
tags: null, tags: null,
trackNumber: item.track || 1, trackNumber: item.track || 1,
uniqueId: nanoid(),
updatedAt: '', updatedAt: '',
userFavorite: item.starred || false, userFavorite: item.starred || false,
userRating: item.userRating || null, userRating: item.userRating || null,
@@ -212,6 +210,8 @@ const normalizeAlbumArtist = (
}) || null; }) || null;
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
albumCount: item.albumCount ? Number(item.albumCount) : 0, albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null, backgroundImageUrl: null,
biography: null, biography: null,
@@ -224,8 +224,6 @@ const normalizeAlbumArtist = (
mbz: null, mbz: null,
name: item.name, name: item.name,
playCount: null, playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [], similarArtists: [],
songCount: null, songCount: null,
userFavorite: false, userFavorite: false,
@@ -247,6 +245,8 @@ const normalizeAlbum = (
}) || null; }) || null;
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
albumArtist: item.artist, albumArtist: item.artist,
albumArtists: getArtistList(item.artists, item.artistId, item.artist), albumArtists: getArtistList(item.artists, item.artistId, item.artist),
artists: [], artists: [],
@@ -276,8 +276,6 @@ const normalizeAlbum = (
releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null, releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null,
releaseTypes: item.releaseTypes || [], releaseTypes: item.releaseTypes || [],
releaseYear: item.year ? Number(item.year) : null, releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null, size: null,
songCount: item.songCount, songCount: item.songCount,
songs: songs:
@@ -285,7 +283,6 @@ const normalizeAlbum = (
normalizeSong(song, server), normalizeSong(song, server),
) || [], ) || [],
tags: null, tags: null,
uniqueId: nanoid(),
updatedAt: item.created, updatedAt: item.created,
userFavorite: item.starred || false, userFavorite: item.starred || false,
userRating: item.userRating || null, userRating: item.userRating || null,
@@ -300,6 +297,8 @@ const normalizePlaylist = (
server?: null | ServerListItemWithCredential, server?: null | ServerListItemWithCredential,
): Playlist => { ): Playlist => {
return { return {
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
description: item.comment || null, description: item.comment || null,
duration: item.duration * 1000, duration: item.duration * 1000,
genres: [], genres: [],
@@ -316,8 +315,6 @@ const normalizePlaylist = (
owner: item.owner, owner: item.owner,
ownerId: item.owner, ownerId: item.owner,
public: item.public, public: item.public,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null, size: null,
songCount: item.songCount, songCount: item.songCount,
}; };
+31 -30
View File
@@ -25,7 +25,7 @@ import {
NDUserListSort, NDUserListSort,
} from '/@/shared/api/navidrome/navidrome-types'; } from '/@/shared/api/navidrome/navidrome-types';
import { ServerFeatures } from '/@/shared/types/features-types'; import { ServerFeatures } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerRepeat, PlayerShuffle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
export enum LibraryItem { export enum LibraryItem {
ALBUM = 'album', ALBUM = 'album',
@@ -58,29 +58,33 @@ export type AnyLibraryItems =
| Song[]; | Song[];
export interface PlayerData { export interface PlayerData {
current: { currentSong: QueueSong | undefined;
index: number; index: number;
nextIndex?: number; muted: boolean;
player: 1 | 2; nextSong: QueueSong | undefined;
previousIndex?: number; num: 1 | 2;
shuffledIndex: number; player1: QueueSong | undefined;
song?: QueueSong; player2: QueueSong | undefined;
status: PlayerStatus; previousSong: QueueSong | undefined;
};
player1?: QueueSong;
player2?: QueueSong;
queue: QueueData; queue: QueueData;
queueLength: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
speed: number;
status: PlayerStatus;
transitionType: PlayerStyle;
volume: number;
} }
export interface QueueData { export interface QueueData {
current?: QueueSong; default: string[];
length: number; priority: string[];
next?: QueueSong; shuffled: string[];
previous?: QueueSong; songs: Record<string, QueueSong>;
} }
export type QueueSong = Song & { export type QueueSong = Song & {
uniqueId: string; _uniqueId: string;
}; };
export type ServerListItem = { export type ServerListItem = {
@@ -161,6 +165,8 @@ export enum ImageType {
} }
export type Album = { export type Album = {
_serverId: string;
_serverType: ServerType;
albumArtist: string; albumArtist: string;
albumArtists: RelatedArtist[]; albumArtists: RelatedArtist[];
artists: RelatedArtist[]; artists: RelatedArtist[];
@@ -185,13 +191,10 @@ export type Album = {
releaseDate: null | string; releaseDate: null | string;
releaseTypes: string[]; releaseTypes: string[];
releaseYear: null | number; releaseYear: null | number;
serverId: string;
serverType: ServerType;
size: null | number; size: null | number;
songCount: null | number; songCount: null | number;
songs?: Song[]; songs?: Song[];
tags: null | Record<string, string[]>; tags: null | Record<string, string[]>;
uniqueId: string;
updatedAt: string; updatedAt: string;
userFavorite: boolean; userFavorite: boolean;
userRating: null | number; userRating: null | number;
@@ -199,6 +202,8 @@ export type Album = {
} & { songs?: Song[] }; } & { songs?: Song[] };
export type AlbumArtist = { export type AlbumArtist = {
_serverId: string;
_serverType: ServerType;
albumCount: null | number; albumCount: null | number;
backgroundImageUrl: null | string; backgroundImageUrl: null | string;
biography: null | string; biography: null | string;
@@ -211,8 +216,6 @@ export type AlbumArtist = {
mbz: null | string; mbz: null | string;
name: string; name: string;
playCount: null | number; playCount: null | number;
serverId: string;
serverType: ServerType;
similarArtists: null | RelatedArtist[]; similarArtists: null | RelatedArtist[];
songCount: null | number; songCount: null | number;
userFavorite: boolean; userFavorite: boolean;
@@ -220,15 +223,14 @@ export type AlbumArtist = {
}; };
export type Artist = { export type Artist = {
_serverId: string;
_serverType: ServerType;
biography: null | string; biography: null | string;
createdAt: string; createdAt: string;
id: string; id: string;
itemType: LibraryItem.ARTIST; itemType: LibraryItem.ARTIST;
name: string; name: string;
remoteCreatedAt: null | string; remoteCreatedAt: null | string;
serverFolderId: string;
serverId: string;
serverType: ServerType;
updatedAt: string; updatedAt: string;
}; };
@@ -297,6 +299,8 @@ export type MusicFolder = {
export type MusicFoldersResponse = MusicFolder[]; export type MusicFoldersResponse = MusicFolder[];
export type Playlist = { export type Playlist = {
_serverId: string;
_serverType: ServerType;
description: null | string; description: null | string;
duration: null | number; duration: null | number;
genres: Genre[]; genres: Genre[];
@@ -309,8 +313,6 @@ export type Playlist = {
ownerId: null | string; ownerId: null | string;
public: boolean | null; public: boolean | null;
rules?: null | Record<string, any>; rules?: null | Record<string, any>;
serverId: string;
serverType: ServerType;
size: null | number; size: null | number;
songCount: null | number; songCount: null | number;
sync?: boolean | null; sync?: boolean | null;
@@ -328,6 +330,8 @@ export type RelatedArtist = {
}; };
export type Song = { export type Song = {
_serverId: string;
_serverType: ServerType;
album: null | string; album: null | string;
albumArtists: RelatedArtist[]; albumArtists: RelatedArtist[];
albumId: string; albumId: string;
@@ -364,13 +368,10 @@ export type Song = {
releaseDate: null | string; releaseDate: null | string;
releaseYear: null | string; releaseYear: null | string;
sampleRate: null | number; sampleRate: null | number;
serverId: string;
serverType: ServerType;
size: number; size: number;
streamUrl: string; streamUrl: string;
tags: null | Record<string, string[]>; tags: null | Record<string, string[]>;
trackNumber: number; trackNumber: number;
uniqueId: string;
updatedAt: string; updatedAt: string;
userFavorite: boolean; userFavorite: boolean;
userRating: null | number; userRating: null | number;
+14 -8
View File
@@ -101,20 +101,16 @@ export enum FontType {
} }
export enum Play { export enum Play {
INDEX = 'index',
LAST = 'last', LAST = 'last',
NEXT = 'next', NEXT = 'next',
NOW = 'now', NOW = 'now',
SHUFFLE = 'shuffle', SHUFFLE = 'shuffle',
} }
export enum PlaybackStyle { export enum PlayerQueueType {
CROSSFADE = 'crossfade', DEFAULT = 'default',
GAPLESS = 'gapless', PRIORITY = 'priority',
}
export enum PlaybackType {
LOCAL = 'local',
WEB = 'web',
} }
export enum PlayerRepeat { export enum PlayerRepeat {
@@ -134,6 +130,16 @@ export enum PlayerStatus {
PLAYING = 'playing', PLAYING = 'playing',
} }
export enum PlayerStyle {
CROSSFADE = 'crossfade',
GAPLESS = 'gapless',
}
export enum PlayerType {
LOCAL = 'local',
WEB = 'web',
}
export enum TableColumn { export enum TableColumn {
ACTIONS = 'actions', ACTIONS = 'actions',
ALBUM = 'album', ALBUM = 'album',