Compare commits

..

4 Commits

Author SHA1 Message Date
jeffvli 6528859ea9 adjust ItemDetail row play control alignment to match ItemTable 2026-05-22 03:43:48 -07:00
jeffvli 704745eb7c fix item table crashing with row play controls
- hovercard causes issues with the image component as the component is mounted/unmounted within the portal
2026-05-22 03:28:37 -07:00
jeffvli 2759cfee97 Revert "fix NativeImage state causing crashes in table"
This reverts commit 4008c8dfdf.
2026-05-22 01:56:50 -07:00
jeffvli 755f0aab9d fix mediasession breaking on player repeat (#1472)
- switch to single web player instance for loop instead of dual-player
- this fixes the issue, but does have a breaking change if using the crossfade player
2026-05-22 01:32:22 -07:00
8 changed files with 151 additions and 75 deletions
@@ -21,7 +21,12 @@ export const ItemDetailRowPlayControlCell = ({
return ( return (
<div className={styles.cellWrapper}> <div className={styles.cellWrapper}>
<HoverCard openDelay={300} position="top" withArrow> <HoverCard
offset={{ crossAxis: 32, mainAxis: 16 }}
openDelay={300}
position="top"
withArrow
>
<HoverCard.Target> <HoverCard.Target>
<div className={styles.playTarget}>{indexContent}</div> <div className={styles.playTarget}>{indexContent}</div>
</HoverCard.Target> </HoverCard.Target>
@@ -88,7 +88,7 @@ export const RowPlayControlCell = (
<TableColumnContainer {...props} className={styles.expansionCell}> <TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}> <div className={styles.expansionInner}>
{showPlayControls ? ( {showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow> <HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>{expansionTarget}</HoverCard.Target> <HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}> <HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} /> <ItemRowPlayControls onPlay={onPlay} />
@@ -110,7 +110,7 @@ export const RowPlayControlCell = (
return ( return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}> <TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow> <HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target> <HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%"> <Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)} {getIndexDisplay(false)}
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
interface WebPlayerEngineProps { interface WebPlayerEngineProps {
isMuted: boolean; isMuted: boolean;
isTransitioning: boolean; isTransitioning: boolean;
loopPlayer1: boolean;
loopPlayer2: boolean;
onEndedPlayer1: () => void; onEndedPlayer1: () => void;
onEndedPlayer2: () => void; onEndedPlayer2: () => void;
onErrorPause: () => void; onErrorPause: () => void;
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const { const {
isMuted, isMuted,
isTransitioning, isTransitioning,
loopPlayer1,
loopPlayer2,
onEndedPlayer1, onEndedPlayer1,
onEndedPlayer2, onEndedPlayer2,
onErrorPause, onErrorPause,
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false} controls={false}
height={0} height={0}
id="web-player-1" id="web-player-1"
loop={loopPlayer1}
muted={isMuted} muted={isMuted}
onEnded={src1 ? () => onEndedPlayer1() : undefined} onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
onError={handleOnError( onError={handleOnError(
player1Ref, player1Ref,
() => onEndedPlayer1(), () => onEndedPlayer1(),
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false} controls={false}
height={0} height={0}
id="web-player-2" id="web-player-2"
loop={loopPlayer2}
muted={isMuted} muted={isMuted}
onEnded={src2 ? () => onEndedPlayer2() : undefined} onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
onError={handleOnError( onError={handleOnError(
player2Ref, player2Ref,
() => onEndedPlayer2(), () => onEndedPlayer2(),
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { import {
WebPlayerEngine, WebPlayerEngine,
WebPlayerEngineHandle, WebPlayerEngineHandle,
@@ -20,12 +21,13 @@ import {
usePlayerData, usePlayerData,
usePlayerMuted, usePlayerMuted,
usePlayerProperties, usePlayerProperties,
usePlayerRepeat,
usePlayerStoreBase, usePlayerStoreBase,
usePlayerVolume, usePlayerVolume,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { QueueSong } from '/@/shared/types/domain-types'; 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_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10; const PLAY_PAUSE_FADE_INTERVAL = 10;
@@ -34,6 +36,8 @@ export function WebPlayer() {
const playerRef = useRef<null | WebPlayerEngineHandle>(null); const playerRef = useRef<null | WebPlayerEngineHandle>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const { num, player1, player2, status } = usePlayerData(); const { num, player1, player2, status } = usePlayerData();
const repeat = usePlayerRepeat();
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions(); const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
const playback = useMpvSettings(); const playback = useMpvSettings();
const { webAudio } = useWebAudio(); 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( const onProgressPlayer1 = useCallback(
(e: PlayerOnProgressProps) => { (e: PlayerOnProgressProps) => {
if (!playerRef.current?.player1()) { if (!playerRef.current?.player1()) {
return; return;
} }
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
return;
}
switch (transitionType) { switch (transitionType) {
case PlayerStyle.CROSSFADE: case PlayerStyle.CROSSFADE:
crossfadeHandler({ crossfadeHandler({
@@ -132,7 +161,17 @@ export function WebPlayer() {
break; break;
} }
}, },
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume], [
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player2,
repeat,
transitionType,
volume,
],
); );
const onProgressPlayer2 = useCallback( const onProgressPlayer2 = useCallback(
@@ -141,6 +180,11 @@ export function WebPlayer() {
return; return;
} }
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
return;
}
switch (transitionType) { switch (transitionType) {
case PlayerStyle.CROSSFADE: case PlayerStyle.CROSSFADE:
crossfadeHandler({ crossfadeHandler({
@@ -170,7 +214,17 @@ export function WebPlayer() {
break; break;
} }
}, },
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume], [
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player1,
repeat,
transitionType,
volume,
],
); );
const handleOnEndedPlayer1 = useCallback(() => { const handleOnEndedPlayer1 = useCallback(() => {
@@ -474,10 +528,15 @@ export function WebPlayer() {
}); });
}, [mediaPause, t]); }, [mediaPause, t]);
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
return ( return (
<WebPlayerEngine <WebPlayerEngine
isMuted={isMuted} isMuted={isMuted}
isTransitioning={Boolean(isTransitioning)} isTransitioning={Boolean(isTransitioning)}
loopPlayer1={loopPlayer1}
loopPlayer2={loopPlayer2}
onEndedPlayer1={handleOnEndedPlayer1} onEndedPlayer1={handleOnEndedPlayer1}
onEndedPlayer2={handleOnEndedPlayer2} onEndedPlayer2={handleOnEndedPlayer2}
onErrorPause={handleOnErrorPause} onErrorPause={handleOnErrorPause}
@@ -136,6 +136,8 @@ export function RadioWebPlayer() {
<WebPlayerEngine <WebPlayerEngine
isMuted={isMuted} isMuted={isMuted}
isTransitioning={false} isTransitioning={false}
loopPlayer1={false}
loopPlayer2={false}
onEndedPlayer1={onEndedPlayer1} onEndedPlayer1={onEndedPlayer1}
onEndedPlayer2={() => {}} onEndedPlayer2={() => {}}
onErrorPause={() => {}} onErrorPause={() => {}}
+56 -7
View File
@@ -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 // Helper function to check if shuffle is enabled
export function isShuffleEnabled(state: { export function isShuffleEnabled(state: {
player: { shuffle: PlayerShuffle }; player: { shuffle: PlayerShuffle };
@@ -800,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
nextSong = calculateNextSong(queueIndex, queue.items, repeat); nextSong = calculateNextSong(queueIndex, queue.items, repeat);
} }
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: queueIndex, // Return the actual queue position for display index: queueIndex, // Return the actual queue position for display
nextSong, nextSong,
num: state.player.playerNum, num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong, player1,
player2: state.player.playerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: state.queue.default.length, queueLength: state.queue.default.length,
status: state.player.status, status: state.player.status,
@@ -895,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
? stateSnapshot.queue.shuffled.length ? stateSnapshot.queue.shuffled.length
: queue.items.length; : queue.items.length;
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
currentIndex, currentIndex,
playbackLength, playbackLength,
repeat, 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
// <audio> element and hardware media keys keep working.
const newPlayerNum = isRepeatOneSameTrack
? player.playerNum
: player.playerNum === 1
? 2
: 1;
const pauseOnNext = player.pauseOnNextSongEnd; const pauseOnNext = player.pauseOnNextSongEnd;
const newStatus = const newStatus =
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
@@ -963,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined; currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
} }
const { player1, player2 } = getDualPlayerSongs(
newPlayerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: currentQueueIndex, index: currentQueueIndex,
nextSong, nextSong,
num: newPlayerNum, num: newPlayerNum,
player1: newPlayerNum === 1 ? currentSong : nextSong, player1,
player2: newPlayerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: queue.items.length, queueLength: queue.items.length,
status: newStatus, status: newStatus,
@@ -1962,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
nextSong = calculateNextSong(queueIndex, queue.items, repeat); nextSong = calculateNextSong(queueIndex, queue.items, repeat);
} }
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: queueIndex, // Return the actual queue position for display index: queueIndex, // Return the actual queue position for display
nextSong, nextSong,
num: state.player.playerNum, num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong, player1,
player2: state.player.playerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: state.queue.default.length, queueLength: state.queue.default.length,
status: state.player.status, status: state.player.status,
+6 -13
View File
@@ -89,18 +89,11 @@ export function BaseImage({
const effectiveImageRequest = const effectiveImageRequest =
isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest; isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;
const imageCacheKey = src ?? rawImageRequest?.cacheKey;
const trackLoadedInInstance = enableViewport;
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false); const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
useEffect(() => { useEffect(() => {
if (!trackLoadedInInstance) { setHasLoadedInInstance(false);
return; }, [effectiveImageRequest?.cacheKey]);
}
setHasLoadedInInstance((current) => (current ? false : current));
}, [imageCacheKey, trackLoadedInInstance]);
const shouldLoadImage = Boolean( const shouldLoadImage = Boolean(
effectiveImageRequest && effectiveImageRequest &&
@@ -119,13 +112,13 @@ export function BaseImage({
}); });
useEffect(() => { useEffect(() => {
if (!trackLoadedInInstance || !nativeImage.isLoaded || !imageCacheKey) { if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
return; return;
} }
loadedImageCacheKeys.add(imageCacheKey); loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);
setHasLoadedInInstance((current) => (current ? current : true)); setHasLoadedInInstance(true);
}, [imageCacheKey, nativeImage.isLoaded, trackLoadedInInstance]); }, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
return ( return (
<ImageContainer <ImageContainer
@@ -26,11 +26,8 @@ export function useNativeImage({
const loadedRequestSignatureRef = useRef<null | string>(null); const loadedRequestSignatureRef = useRef<null | string>(null);
const objectUrlRef = useRef<null | string>(null); const objectUrlRef = useRef<null | string>(null);
const onFetchErrorRef = useRef(onFetchError); const onFetchErrorRef = useRef(onFetchError);
const requestRef = useRef(request);
const [state, setState] = useState<NativeImageState>({ status: 'idle' }); const [state, setState] = useState<NativeImageState>({ status: 'idle' });
requestRef.current = request;
const requestSignature = useMemo(() => { const requestSignature = useMemo(() => {
if (!request) { if (!request) {
return null; return null;
@@ -47,8 +44,6 @@ export function useNativeImage({
onFetchErrorRef.current = onFetchError; onFetchErrorRef.current = onFetchError;
useEffect(() => { useEffect(() => {
const request = requestRef.current;
const abortCurrentRequest = () => { const abortCurrentRequest = () => {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
@@ -67,61 +62,28 @@ export function useNativeImage({
if (!request || !requestSignature) { if (!request || !requestSignature) {
abortCurrentRequest(); abortCurrentRequest();
revokeObjectUrl(); revokeObjectUrl();
setState((currentState) => { setState({ status: 'idle' });
if (currentState.status === 'idle' && !currentState.displaySrc) {
return currentState;
}
return { status: 'idle' };
});
return; return;
} }
if (!enabled) { if (!enabled) {
abortCurrentRequest(); abortCurrentRequest();
setState((currentState) => { setState((currentState) =>
if (currentState.displaySrc) { currentState.displaySrc
if (currentState.status === 'loaded') { ? { ...currentState, status: 'loaded' }
return currentState; : { status: 'idle' },
} );
return { ...currentState, status: 'loaded' };
}
if (currentState.status === 'idle' && !currentState.displaySrc) {
return currentState;
}
return { status: 'idle' };
});
return; return;
} }
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) { if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
const cachedObjectUrl = objectUrlRef.current; setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
setState((currentState) => {
if (
currentState.status === 'loaded' &&
currentState.displaySrc === cachedObjectUrl
) {
return currentState;
}
return { displaySrc: cachedObjectUrl, status: 'loaded' };
});
return; return;
} }
abortCurrentRequest(); abortCurrentRequest();
revokeObjectUrl(); revokeObjectUrl();
setState((currentState) => { setState({ status: 'loading' });
if (currentState.status === 'loading' && !currentState.displaySrc) {
return currentState;
}
return { status: 'loading' };
});
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
@@ -176,7 +138,7 @@ export function useNativeImage({
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}; };
}, [enabled, fetchPriority, requestSignature]); }, [enabled, fetchPriority, request, requestSignature]);
useEffect(() => { useEffect(() => {
return () => { return () => {