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 a85af1df6..d8ffc919f 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 @@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer { interface WebPlayerEngineProps { isMuted: boolean; isTransitioning: boolean; + loopPlayer1: boolean; + loopPlayer2: boolean; onEndedPlayer1: () => void; onEndedPlayer2: () => void; onErrorPause: () => void; @@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { const { isMuted, isTransitioning, + loopPlayer1, + loopPlayer2, onEndedPlayer1, onEndedPlayer2, onErrorPause, @@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { controls={false} height={0} id="web-player-1" + loop={loopPlayer1} muted={isMuted} - onEnded={src1 ? () => onEndedPlayer1() : undefined} + onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined} onError={handleOnError( player1Ref, () => onEndedPlayer1(), @@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { controls={false} height={0} id="web-player-2" + loop={loopPlayer2} muted={isMuted} - onEnded={src2 ? () => onEndedPlayer2() : undefined} + onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined} onError={handleOnError( player2Ref, () => onEndedPlayer2(), diff --git a/src/renderer/features/player/audio-player/web-player.tsx b/src/renderer/features/player/audio-player/web-player.tsx index 4b3b0344f..4e922bbcf 100644 --- a/src/renderer/features/player/audio-player/web-player.tsx +++ b/src/renderer/features/player/audio-player/web-player.tsx @@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; import { WebPlayerEngine, WebPlayerEngineHandle, @@ -20,12 +21,13 @@ import { usePlayerData, usePlayerMuted, usePlayerProperties, + usePlayerRepeat, usePlayerStoreBase, usePlayerVolume, } from '/@/renderer/store'; import { toast } from '/@/shared/components/toast/toast'; import { QueueSong } from '/@/shared/types/domain-types'; -import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types'; +import { CrossfadeStyle, PlayerRepeat, PlayerStatus, PlayerStyle } from '/@/shared/types/types'; const PLAY_PAUSE_FADE_DURATION = 300; const PLAY_PAUSE_FADE_INTERVAL = 10; @@ -34,6 +36,8 @@ export function WebPlayer() { const playerRef = useRef(null); const { t } = useTranslation(); const { num, player1, player2, status } = usePlayerData(); + const repeat = usePlayerRepeat(); + const repeatOneProgressRef = useRef({ player1: 0, player2: 0 }); const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions(); const playback = useMpvSettings(); const { webAudio } = useWebAudio(); @@ -97,12 +101,37 @@ export function WebPlayer() { [], ); + const handleRepeatOne = useCallback( + (playerId: 1 | 2, playedSeconds: number, duration: number) => { + if (repeat !== PlayerRepeat.ONE || duration <= 0 || num !== playerId) { + return; + } + + const key = playerId === 1 ? 'player1' : 'player2'; + const last = repeatOneProgressRef.current[key]; + repeatOneProgressRef.current[key] = playedSeconds; + + if (last > duration * 0.85 && playedSeconds < duration * 0.15) { + setTimestamp(0); + eventEmitter.emit('PLAYER_REPEATED', { + index: usePlayerStoreBase.getState().player.index, + }); + } + }, + [num, repeat, setTimestamp], + ); + const onProgressPlayer1 = useCallback( (e: PlayerOnProgressProps) => { if (!playerRef.current?.player1()) { return; } + if (repeat === PlayerRepeat.ONE) { + handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref)); + return; + } + switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ @@ -132,7 +161,17 @@ export function WebPlayer() { break; } }, - [crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume], + [ + crossfadeDuration, + crossfadeStyle, + handleRepeatOne, + isTransitioning, + num, + player2, + repeat, + transitionType, + volume, + ], ); const onProgressPlayer2 = useCallback( @@ -141,6 +180,11 @@ export function WebPlayer() { return; } + if (repeat === PlayerRepeat.ONE) { + handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref)); + return; + } + switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ @@ -170,7 +214,17 @@ export function WebPlayer() { break; } }, - [crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume], + [ + crossfadeDuration, + crossfadeStyle, + handleRepeatOne, + isTransitioning, + num, + player1, + repeat, + transitionType, + volume, + ], ); const handleOnEndedPlayer1 = useCallback(() => { @@ -474,10 +528,15 @@ export function WebPlayer() { }); }, [mediaPause, t]); + const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1; + const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2; + return ( {}} onErrorPause={() => {}} diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index b2d2c18e5..11cb99096 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -139,6 +139,25 @@ export function calculateNextSong( } } +export function getDualPlayerSongs( + playerNum: 1 | 2, + currentSong: QueueSong | undefined, + nextSong: QueueSong | undefined, + repeat: PlayerRepeat, +): { player1: QueueSong | undefined; player2: QueueSong | undefined } { + if (repeat === PlayerRepeat.ONE) { + return { + player1: playerNum === 1 ? currentSong : undefined, + player2: playerNum === 2 ? currentSong : undefined, + }; + } + + return { + player1: playerNum === 1 ? currentSong : nextSong, + player2: playerNum === 2 ? currentSong : nextSong, + }; +} + // Helper function to check if shuffle is enabled export function isShuffleEnabled(state: { player: { shuffle: PlayerShuffle }; @@ -800,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn()( nextSong = calculateNextSong(queueIndex, queue.items, repeat); } + const { player1, player2 } = getDualPlayerSongs( + state.player.playerNum, + currentSong, + nextSong, + repeat, + ); + return { currentSong, index: queueIndex, // Return the actual queue position for display nextSong, num: state.player.playerNum, - player1: state.player.playerNum === 1 ? currentSong : nextSong, - player2: state.player.playerNum === 2 ? currentSong : nextSong, + player1, + player2, previousSong, queueLength: state.queue.default.length, status: state.player.status, @@ -895,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn()( ? stateSnapshot.queue.shuffled.length : queue.items.length; - const newPlayerNum = player.playerNum === 1 ? 2 : 1; const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( currentIndex, playbackLength, repeat, ); + const isRepeatOneSameTrack = + repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex; + // Dual web players alternate for gapless/crossfade between tracks. Repeat-one + // replays the same track — keep playerNum so Chromium stays bound to the same + //