diff --git a/src/renderer/components/card/poster-card.tsx b/src/renderer/components/card/poster-card.tsx index ba314d8b9..8a2036957 100644 --- a/src/renderer/components/card/poster-card.tsx +++ b/src/renderer/components/card/poster-card.tsx @@ -59,7 +59,6 @@ export const PosterCard = ({ 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, 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, serverId: string) => { diff --git a/src/renderer/components/virtual-table/hooks/use-current-song-row-styles.ts b/src/renderer/components/virtual-table/hooks/use-current-song-row-styles.ts index bbf46ec54..04e67a4fe 100644 --- a/src/renderer/components/virtual-table/hooks/use-current-song-row-styles.ts +++ b/src/renderer/components/virtual-table/hooks/use-current-song-row-styles.ts @@ -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 || {}; diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 1866bcdfa..72a508c93 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -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(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(); - const playbackType = usePlaybackType(); - // const { play } = usePlayerControls(); - const volume = usePlayerVolume(); const isFocused = useAppFocus(); const isFocusedRef = useRef(isFocused); diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx new file mode 100644 index 000000000..9f83d18d6 --- /dev/null +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -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; + 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(currentSrc); + + const progressIntervalRef = useRef(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(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
; +}; + +MpvPlayerEngine.displayName = 'MpvPlayerEngine'; diff --git a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx index 4f30a4834..0de875aa2 100644 --- a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx @@ -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; playerStatus: PlayerStatus; diff --git a/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx b/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx new file mode 100644 index 000000000..615958212 --- /dev/null +++ b/src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx @@ -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, + ]); +}; diff --git a/src/renderer/features/player/audio-player/listener/player-events.tsx b/src/renderer/features/player/audio-player/hooks/use-player-events.ts similarity index 84% rename from src/renderer/features/player/audio-player/listener/player-events.tsx rename to src/renderer/features/player/audio-player/hooks/use-player-events.ts index bfd467cce..e5ac3d471 100644 --- a/src/renderer/features/player/audio-player/listener/player-events.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-player-events.ts @@ -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 diff --git a/src/renderer/features/player/audio-player/listener/use-player-events.ts b/src/renderer/features/player/audio-player/listener/use-player-events.ts deleted file mode 100644 index 758686bbb..000000000 --- a/src/renderer/features/player/audio-player/listener/use-player-events.ts +++ /dev/null @@ -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]); -} diff --git a/src/renderer/features/player/audio-player/mpv-player.tsx b/src/renderer/features/player/audio-player/mpv-player.tsx new file mode 100644 index 000000000..532f29ba1 --- /dev/null +++ b/src/renderer/features/player/audio-player/mpv-player.tsx @@ -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(null); + const { currentSong, nextSong, status } = usePlayerData(); + const { mediaAutoNext, setTimestamp } = usePlayerActions(); + const { speed } = usePlayerProperties(); + const isMuted = usePlayerMuted(); + const volume = usePlayerVolume(); + + const [localPlayerStatus, setLocalPlayerStatus] = useState(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 ( + + ); +} diff --git a/src/renderer/features/player/audio-player/types.ts b/src/renderer/features/player/audio-player/types.ts index 6273510e7..e9d19776d 100644 --- a/src/renderer/features/player/audio-player/types.ts +++ b/src/renderer/features/player/audio-player/types.ts @@ -6,3 +6,8 @@ export interface AudioPlayer { seekTo(seekTo: number): void; setVolume(volume: number): void; } + +export interface PlayerOnProgressProps { + played: number; + playedSeconds: number; +} diff --git a/src/renderer/features/player/audio-player/web-player.tsx b/src/renderer/features/player/audio-player/web-player.tsx index 4d675af8e..2d7ba6f9b 100644 --- a/src/renderer/features/player/audio-player/web-player.tsx +++ b/src/renderer/features/player/audio-player/web-player.tsx @@ -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(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 ( {
{playbackType === PlayerType.WEB && } + {playbackType === PlayerType.LOCAL && } ); }; diff --git a/src/renderer/features/player/hooks/use-center-controls.ts b/src/renderer/features/player/hooks/use-center-controls.ts index 1623c853a..ea9687b06 100644 --- a/src/renderer/features/player/hooks/use-center-controls.ts +++ b/src/renderer/features/player/hooks/use-center-controls.ts @@ -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, diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index 8c41b0973..4e80439bb 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -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); - } }} /> ), diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index e4fc7f487..c7ba1b35e 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -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()( 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()( 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()( } 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()( 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()( 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()( } 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()( 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()( 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()( (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 = {}; + 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()( 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()( 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()( 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()( 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()( 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()( 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()( default: [], priority: [], shuffled: [], - songs: [], + songs: {}, }, setCrossfadeDuration: (duration: number) => { set((state) => { @@ -1034,11 +962,6 @@ export const usePlayerStoreBase = create()( 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()( 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() { diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index a5e0c67b9..afc14ab4e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -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; } export type QueueSong = Song & {