re-implement mpv with new player

This commit is contained in:
jeffvli
2025-11-05 19:01:15 -08:00
parent f2e3e7a74e
commit f30a466fb2
17 changed files with 642 additions and 233 deletions
@@ -59,7 +59,6 @@ export const PosterCard = ({
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
isHovered={isHovered}
itemData={data}
itemType={controls.itemType}
@@ -9,16 +9,16 @@ import {
PLAYLIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Icon } from '/@/shared/components/icon/icon';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play, PlayQueueAddOptions } from '/@/shared/types/types';
import { Play } from '/@/shared/types/types';
export const GridCardControls = ({
handleFavorite,
handlePlayQueueAdd,
isHovered,
itemData,
itemType,
@@ -30,7 +30,6 @@ export const GridCardControls = ({
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
isHovered?: boolean;
itemData: any;
itemType: LibraryItem;
@@ -39,17 +38,18 @@ export const GridCardControls = ({
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
const playButtonBehavior = usePlayButtonBehavior();
const player = usePlayerContext();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
handlePlayQueueAdd?.({
byItemType: {
id: [itemData.id],
type: itemType,
},
playType: playType || playButtonBehavior,
});
player.addToQueueByFetch(
itemData._serverId,
[itemData.id],
itemType,
playType || playButtonBehavior,
);
};
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
@@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { RowClassRules, RowNode } from '@ag-grid-community/core';
import { MutableRefObject, useEffect, useMemo, useRef } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/listener/use-player-events';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useAppFocus } from '/@/renderer/hooks';
import { usePlayerSong } from '/@/renderer/store';
import { Song } from '/@/shared/types/domain-types';
@@ -75,8 +75,8 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
api.redrawRows({ rowNodes });
}
},
onPlayerStatus: (properties) => {
const song = properties.song;
onPlayerStatus: () => {
const song = currentSong;
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
@@ -1,15 +1,9 @@
import type {
CellDoubleClickedEvent,
RowClassRules,
RowDragEvent,
RowNode,
} from '@ag-grid-community/core';
import type { RowClassRules, RowDragEvent, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import type { Ref } from 'react';
import { useMergedRef } from '@mantine/hooks';
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 { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
@@ -19,29 +13,22 @@ import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-tabl
import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback';
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 { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { useAppFocus } from '/@/renderer/hooks';
import {
useAppStoreActions,
usePlayerQueue,
usePlayerSong,
usePlayerStatus,
usePlayerVolume,
} from '/@/renderer/store';
import {
PersistedTableColumn,
usePlaybackType,
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
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 { PlayerType, TableType } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
import { TableType } from '/@/shared/types/types';
type QueueProps = {
searchTerm?: string;
@@ -52,17 +39,12 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const queue = usePlayerQueue();
// const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = usePlayerSong();
// const previousSong = usePreviousSong();
const status = usePlayerStatus();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playbackType = usePlaybackType();
// const { play } = usePlayerControls();
const volume = usePlayerVolume();
const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused);
@@ -0,0 +1,221 @@
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 { 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 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);
// 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';
@@ -3,17 +3,10 @@ import type { RefObject } from 'react';
import { useImperativeHandle, useRef, useState } from 'react';
import ReactPlayer from 'react-player';
import { AudioPlayer } from '/@/renderer/features/player/audio-player/types';
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 OnProgressProps {
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
}
export interface WebPlayerEngineHandle extends AudioPlayer {
player1(): {
ref: null | ReactPlayer;
@@ -30,8 +23,8 @@ interface WebPlayerEngineProps {
isTransitioning: boolean;
onEndedPlayer1: () => void;
onEndedPlayer2: () => void;
onProgressPlayer1: (e: OnProgressProps) => void;
onProgressPlayer2: (e: OnProgressProps) => void;
onProgressPlayer1: (e: PlayerOnProgressProps) => void;
onProgressPlayer2: (e: PlayerOnProgressProps) => void;
playerNum: number;
playerRef: RefObject<WebPlayerEngineHandle>;
playerStatus: PlayerStatus;
@@ -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,
]);
};
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import {
QueueData,
subscribeCurrentTrack,
subscribePlayerMute,
subscribePlayerProgress,
@@ -9,14 +10,14 @@ import {
subscribePlayerStatus,
subscribePlayerVolume,
} from '/@/renderer/store';
import { QueueSong } from '/@/shared/types/domain-types';
import { QueueData, QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
export interface PlayerEvents {
interface PlayerEvents {
cleanup: () => void;
}
export interface PlayerEventsCallbacks {
interface PlayerEventsCallbacks {
onCurrentSongChange?: (
properties: { index: number; song: QueueSong | undefined },
prev: { index: number; song: QueueSong | undefined },
@@ -34,7 +35,18 @@ export interface PlayerEventsCallbacks {
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
}
export function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
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
@@ -1,17 +0,0 @@
import { useEffect } from 'react';
import {
createPlayerEvents,
PlayerEventsCallbacks,
} from '/@/renderer/features/player/audio-player/listener/player-events';
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]);
}
@@ -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}
/>
);
}
@@ -6,3 +6,8 @@ export interface AudioPlayer {
seekTo(seekTo: number): void;
setVolume(volume: number): void;
}
export interface PlayerOnProgressProps {
played: number;
playedSeconds: number;
}
@@ -2,11 +2,14 @@ import type { Dispatch } from 'react';
import type ReactPlayer from 'react-player';
import { useCallback, useRef, useState } from 'react';
import { OnProgressProps } from 'react-player/base';
import { WebPlayerEngine, WebPlayerEngineHandle } from './engine/web-player-engine';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/listener/use-player-events';
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,
@@ -22,7 +25,7 @@ const PLAY_PAUSE_FADE_INTERVAL = 10;
export function WebPlayer() {
const playerRef = useRef<WebPlayerEngineHandle>(null);
const { num, player1, player2, status } = usePlayerData();
const { mediaAutoNext, setProgress } = usePlayerActions();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { crossfadeDuration, speed, transitionType } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
@@ -67,11 +70,11 @@ export function WebPlayer() {
);
const onProgressPlayer1 = useCallback(
(e: OnProgressProps) => {
(e: PlayerOnProgressProps) => {
if (transitionType === 'crossfade' && num === 1) {
setProgress(Number(e.playedSeconds.toFixed(0)));
setTimestamp(Number(e.playedSeconds.toFixed(0)));
} else if (transitionType === 'gapless') {
setProgress(Number(e.playedSeconds.toFixed(0)));
setTimestamp(Number(e.playedSeconds.toFixed(0)));
}
if (!playerRef.current?.player1()) {
@@ -105,15 +108,15 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, setProgress, transitionType, volume],
[crossfadeDuration, isTransitioning, num, setTimestamp, transitionType, volume],
);
const onProgressPlayer2 = useCallback(
(e: OnProgressProps) => {
(e: PlayerOnProgressProps) => {
if (transitionType === PlayerStyle.CROSSFADE && num === 2) {
setProgress(Number(e.playedSeconds.toFixed(0)));
setTimestamp(Number(e.playedSeconds.toFixed(0)));
} else if (transitionType === PlayerStyle.GAPLESS) {
setProgress(Number(e.playedSeconds.toFixed(0)));
setTimestamp(Number(e.playedSeconds.toFixed(0)));
}
if (!playerRef.current?.player2()) {
@@ -147,7 +150,7 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, setProgress, transitionType, volume],
[crossfadeDuration, isTransitioning, num, setTimestamp, transitionType, volume],
);
const handleOnEndedPlayer1 = useCallback(() => {
@@ -202,6 +205,8 @@ export function WebPlayer() {
[volume, num, isTransitioning],
);
useMainPlayerListener();
return (
<WebPlayerEngine
isMuted={isMuted}
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
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 {
@@ -113,6 +114,7 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
</div>
</div>
{playbackType === PlayerType.WEB && <WebPlayer />}
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
</>
);
};
@@ -4,7 +4,7 @@
// import { useTranslation } from 'react-i18next';
// import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
// import { updateSong } from '/@/renderer/features/player/update-remote-song';
// import { updateSong } from '/@/renderer/features/plaayer/update-remote-song';
// import {
// usePlayerNum,
// usePlayerStatus,
@@ -6,14 +6,13 @@ import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { usePlayerStatus, usePlayerStore } from '/@/renderer/store';
import { usePlayerStatus } from '/@/renderer/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 { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast';
import { CrossfadeStyle, PlayerStyle, PlayerType, PlayerStatus } from '/@/shared/types/types';
import { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
@@ -65,10 +64,6 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
onChange={(e) => {
setSettings({ playback: { ...settings, type: e as PlayerType } });
ipc?.send('settings-set', { property: 'playbackType', value: e });
if (isElectron() && e === PlayerType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
setQueue(queueData);
}
}}
/>
),
+64 -129
View File
@@ -8,7 +8,7 @@ import { useShallow } from 'zustand/react/shallow';
import { createSelectors } from '/@/renderer/lib/zustand';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { QueueSong, Song } from '/@/shared/types/domain-types';
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
import {
Play,
PlayerQueueType,
@@ -18,34 +18,8 @@ import {
PlayerStyle,
} from '/@/shared/types/types';
export interface PlayerData {
currentSong: QueueSong | undefined;
index: number;
muted: boolean;
nextSong: QueueSong | undefined;
num: 1 | 2;
player1: QueueSong | undefined;
player2: QueueSong | undefined;
previousSong: QueueSong | undefined;
queue: QueueData;
queueLength: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
speed: number;
status: PlayerStatus;
transitionType: PlayerStyle;
volume: number;
}
export interface PlayerState extends Actions, State {}
export interface QueueData {
default: string[];
priority: string[];
shuffled: string[];
songs: QueueSong[];
}
export type QueueGroupingProperty = keyof QueueSong;
interface Actions {
@@ -77,11 +51,11 @@ interface Actions {
moveSelectedToNext: (items: QueueSong[]) => void;
moveSelectedToTop: (items: QueueSong[]) => void;
setCrossfadeDuration: (duration: number) => void;
setProgress: (timestamp: number) => void;
setQueueType: (queueType: PlayerQueueType) => void;
setRepeat: (repeat: PlayerRepeat) => void;
setShuffle: (shuffle: PlayerShuffle) => void;
setSpeed: (speed: number) => void;
setTimestamp: (timestamp: number) => void;
setTransitionType: (transitionType: PlayerStyle) => void;
setVolume: (volume: number) => void;
shuffle: () => void;
@@ -131,15 +105,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
case Play.LAST: {
set((state) => {
const currentIndex = state.player.index;
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
state.queue.default = [
@@ -163,15 +131,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
case Play.NEXT: {
set((state) => {
const currentIndex = state.player.index;
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
state.queue.default = [
@@ -195,15 +157,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
}
case Play.NOW: {
set((state) => {
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
state.queue.default = [];
@@ -226,15 +182,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
switch (playType) {
case Play.LAST: {
set((state) => {
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
state.queue.priority = [
@@ -254,15 +204,10 @@ export const usePlayerStoreBase = create<PlayerState>()(
const currentIndex = state.player.index;
const isInPriority =
currentIndex < state.queue.priority.length;
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
if (isInPriority) {
@@ -291,15 +236,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
}
case Play.NOW: {
set((state) => {
const existingIds = new Set(
state.queue.songs.map((s) => s._uniqueId),
);
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
state.queue.default = [];
@@ -376,13 +315,9 @@ export const usePlayerStoreBase = create<PlayerState>()(
const queueType = getQueueType();
set((state) => {
const existingIds = new Set(state.queue.songs.map((s) => s._uniqueId));
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
newItems.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
if (queueType === PlayerQueueType.DEFAULT) {
@@ -459,7 +394,7 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.index = -1;
state.queue.default = [];
state.queue.priority = [];
state.queue.songs = [];
state.queue.songs = {};
});
},
clearSelected: (items: QueueSong[]) => {
@@ -474,14 +409,18 @@ export const usePlayerStoreBase = create<PlayerState>()(
(id) => !uniqueIds.includes(id),
);
// Remove songs from songs array if they're not in default or priority
// Remove songs from songs object if they're not in default or priority
const remainingIds = new Set([
...state.queue.default,
...state.queue.priority,
]);
state.queue.songs = state.queue.songs.filter((song) =>
remainingIds.has(song._uniqueId),
);
const filteredSongs: Record<string, QueueSong> = {};
Object.values(state.queue.songs).forEach((song) => {
if (remainingIds.has(song._uniqueId)) {
filteredSongs[song._uniqueId] = song;
}
});
state.queue.songs = filteredSongs;
const newQueue = [...state.queue.priority, ...state.queue.default];
@@ -539,9 +478,7 @@ export const usePlayerStoreBase = create<PlayerState>()(
getQueueOrder: () => {
const queueType = getQueueType();
const state = get();
const songsMap = new Map(
state.queue.songs.map((song) => [song._uniqueId, song]),
);
const songsMap = new Map(Object.entries(state.queue.songs));
if (queueType === PlayerQueueType.PRIORITY) {
const defaultIds = state.queue.default;
@@ -704,7 +641,7 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.index = -1;
state.queue.default = [];
state.queue.priority = [];
state.queue.songs = [];
state.queue.songs = {};
});
},
mediaToggleMute: () => {
@@ -726,12 +663,12 @@ export const usePlayerStoreBase = create<PlayerState>()(
const itemUniqueIds = items.map((item) => item._uniqueId);
set((state) => {
const existingIds = new Set(state.queue.songs.map((s) => s._uniqueId));
const existingIds = new Set(Object.keys(state.queue.songs));
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object (avoiding duplicates)
items.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
state.queue.songs[item._uniqueId] = item;
}
});
@@ -829,13 +766,10 @@ export const usePlayerStoreBase = create<PlayerState>()(
moveSelectedToBottom: (items: QueueSong[]) => {
set((state) => {
const uniqueIds = items.map((item) => item._uniqueId);
const existingIds = new Set(state.queue.songs.map((s) => s._uniqueId));
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
items.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
if (state.player.queueType === PlayerQueueType.PRIORITY) {
@@ -878,13 +812,10 @@ export const usePlayerStoreBase = create<PlayerState>()(
const uniqueId = currentTrack?._uniqueId;
const uniqueIds = items.map((item) => item._uniqueId);
const existingIds = new Set(state.queue.songs.map((s) => s._uniqueId));
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
items.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
if (queueType === PlayerQueueType.DEFAULT) {
@@ -966,13 +897,10 @@ export const usePlayerStoreBase = create<PlayerState>()(
moveSelectedToTop: (items: QueueSong[]) => {
set((state) => {
const uniqueIds = items.map((item) => item._uniqueId);
const existingIds = new Set(state.queue.songs.map((s) => s._uniqueId));
// Add new songs to songs array (avoiding duplicates)
// Add new songs to songs object
items.forEach((item) => {
if (!existingIds.has(item._uniqueId)) {
state.queue.songs.push(item);
}
state.queue.songs[item._uniqueId] = item;
});
if (state.player.queueType === PlayerQueueType.PRIORITY) {
@@ -1026,7 +954,7 @@ export const usePlayerStoreBase = create<PlayerState>()(
default: [],
priority: [],
shuffled: [],
songs: [],
songs: {},
},
setCrossfadeDuration: (duration: number) => {
set((state) => {
@@ -1034,11 +962,6 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.crossfadeDuration = normalizedDuration;
});
},
setProgress: (timestamp: number) => {
set((state) => {
state.player.timestamp = timestamp;
});
},
setQueueType: (queueType: PlayerQueueType) => {
set((state) => {
// From default -> priority, move all items from default to priority
@@ -1075,6 +998,11 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.speed = normalizedSpeed;
});
},
setTimestamp: (timestamp: number) => {
set((state) => {
state.player.timestamp = timestamp;
});
},
setTransitionType: (transitionType: PlayerStyle) => {
set((state) => {
state.player.transitionType = transitionType;
@@ -1178,10 +1106,10 @@ export const usePlayerActions = () => {
moveSelectedToNext: state.moveSelectedToNext,
moveSelectedToTop: state.moveSelectedToTop,
setCrossfadeDuration: state.setCrossfadeDuration,
setProgress: state.setProgress,
setRepeat: state.setRepeat,
setShuffle: state.setShuffle,
setSpeed: state.setSpeed,
setTimestamp: state.setTimestamp,
setTransitionType: state.setTransitionType,
setVolume: state.setVolume,
shuffle: state.shuffle,
@@ -1379,13 +1307,12 @@ export const usePlayerData = (): PlayerData => {
export const updateQueueFavorites = (ids: string[], favorite: boolean) => {
usePlayerStoreBase.setState((state) => {
// Update songs in the songs array
state.queue.songs.forEach((song) => {
// Update songs in the songs object
Object.values(state.queue.songs).forEach((song) => {
if (ids.includes(song.id)) {
song.userFavorite = favorite;
}
});
// default and priority are just uniqueId arrays, so they don't need updating
});
};
@@ -1434,18 +1361,26 @@ export const usePlayerNum = () => {
};
export const usePlayerQueue = () => {
return usePlayerStoreBase((state) => {
const queueType = state.player.queueType;
return usePlayerStoreBase(
useShallow((state) => {
const queueType = state.player.queueType;
switch (queueType) {
case PlayerQueueType.DEFAULT:
return state.queue.default;
case PlayerQueueType.PRIORITY:
return state.queue.priority;
default:
return state.queue.default;
}
});
switch (queueType) {
case PlayerQueueType.DEFAULT: {
const queue = state.queue.default;
return queue.map((id) => state.queue.songs[id]);
}
case PlayerQueueType.PRIORITY: {
const priorityQueue = state.queue.priority;
return priorityQueue.map((id) => state.queue.songs[id]);
}
default: {
const defaultQueue = state.queue.default;
return defaultQueue.map((id) => state.queue.songs[id]);
}
}
}),
);
};
function getQueueType() {
+20 -16
View File
@@ -25,7 +25,7 @@ import {
NDUserListSort,
} from '/@/shared/api/navidrome/navidrome-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 {
ALBUM = 'album',
@@ -58,25 +58,29 @@ export type AnyLibraryItems =
| Song[];
export interface PlayerData {
current: {
index: number;
nextIndex?: number;
player: 1 | 2;
previousIndex?: number;
shuffledIndex: number;
song?: QueueSong;
status: PlayerStatus;
};
player1?: QueueSong;
player2?: QueueSong;
currentSong: QueueSong | undefined;
index: number;
muted: boolean;
nextSong: QueueSong | undefined;
num: 1 | 2;
player1: QueueSong | undefined;
player2: QueueSong | undefined;
previousSong: QueueSong | undefined;
queue: QueueData;
queueLength: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
speed: number;
status: PlayerStatus;
transitionType: PlayerStyle;
volume: number;
}
export interface QueueData {
current?: QueueSong;
length: number;
next?: QueueSong;
previous?: QueueSong;
default: string[];
priority: string[];
shuffled: string[];
songs: Record<string, QueueSong>;
}
export type QueueSong = Song & {