add "stopped" playback state and event handlers

This commit is contained in:
jeffvli
2026-06-29 20:23:46 -07:00
parent a221a84792
commit 37ada07ee2
14 changed files with 147 additions and 40 deletions
+6 -1
View File
@@ -124,7 +124,12 @@ ipcMain.on('update-volume', (_event, volume) => {
});
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
mprisPlayer.playbackStatus =
status === PlayerStatus.PLAYING
? 'Playing'
: status === PlayerStatus.STOPPED
? 'Stopped'
: 'Paused';
});
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
+1 -1
View File
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
} else {
send({ event: 'play' });
}
}}
+7
View File
@@ -13,6 +13,7 @@ export type EventMap = {
MPV_RELOAD: MpvReloadEventPayload;
PLAYER_PLAY: PlayerPlayEventPayload;
PLAYER_REPEATED: PlayerRepeatedEventPayload;
PLAYER_STOP: PlayerStopEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
@@ -54,6 +55,12 @@ export type PlayerRepeatedEventPayload = {
index: number;
};
export type PlayerStopEventPayload = {
id?: string;
index?: number;
reset: boolean;
};
export type PlaylistMoveEventPayload = {
playlistId: string;
sourceIds: string[];
@@ -212,7 +212,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (playerStatus === PlayerStatus.PLAYING) {
mpvPlayer.play();
} else if (playerStatus === PlayerStatus.PAUSED) {
} else {
mpvPlayer.pause();
}
}, [playerStatus]);
@@ -47,6 +47,7 @@ interface PlayerEventsCallbacks {
) => void;
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
onPlayerStop?: (properties: { id?: string; index?: number; reset: boolean }) => void;
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
onQueueCleared?: () => void;
onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;
@@ -166,6 +167,10 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
eventEmitter.on('PLAYER_REPEATED', callbacks.onPlayerRepeated);
}
if (callbacks.onPlayerStop) {
eventEmitter.on('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -193,6 +198,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
if (callbacks.onPlayerRepeated) {
eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated);
}
if (callbacks.onPlayerStop) {
eventEmitter.off('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -69,12 +69,12 @@ export function MpvPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[],
@@ -111,18 +111,18 @@ export function MpvPlayer() {
const status = properties.status;
const volume = usePlayerStore.getState().player.volume;
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(status);
}
}
},
@@ -60,12 +60,12 @@ export function WaveSurferPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[isTransitioning],
@@ -190,10 +190,10 @@ export function WaveSurferPlayer() {
},
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) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
},
onPlayerVolume: (properties) => {
@@ -89,13 +89,13 @@ export function WebPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
playerRef.current?.setVolume(startVolume);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
}
},
[],
@@ -241,7 +241,7 @@ export function WebPlayer() {
// If mediaAutoNext resulted in a paused state (e.g. end of queue,
// or pauseOnNextSongEnd flag), stop all audio instead of restoring volume.
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -260,7 +260,7 @@ export function WebPlayer() {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -313,9 +313,9 @@ export function WebPlayer() {
const status = properties.status;
// Reset crossfade transition if paused during a crossfade transition
// Reset crossfade transition if paused/stopped during a crossfade transition
if (
status === PlayerStatus.PAUSED &&
status !== PlayerStatus.PLAYING &&
isTransitioning &&
transitionType === PlayerStyle.CROSSFADE
) {
@@ -331,18 +331,18 @@ export function WebPlayer() {
}
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(status);
}
}
},
@@ -203,7 +203,7 @@ const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => {
return (
<MainPlayButton
disabled={disabled || currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
/>
);
@@ -51,7 +51,7 @@ export const MobileFullscreenPlayerControls = memo(
/>
<MainPlayButton
disabled={currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
style={{
height: '50px',
@@ -213,7 +213,7 @@ export const MobilePlayerbar = () => {
/>
<MainPlayButton
disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={(e) => {
e.stopPropagation();
mediaTogglePlayPause();
@@ -131,6 +131,7 @@ export const useScrobble = () => {
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const stopPositionRef = useRef<number>(0);
const stoppedSongIdRef = useRef<string | undefined>(undefined);
const lastProgressEventRef = useRef<number>(0);
const lastSeekEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -499,6 +500,12 @@ export const useScrobble = () => {
const currentStatus = usePlayerStore.getState().player.status;
// Stop resets seek position; the stop event is reported by handleScrobbleFromStatus.
if (currentStatus === PlayerStatus.STOPPED) {
flushScrobbleDebug();
return;
}
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
@@ -608,6 +615,71 @@ export const useScrobble = () => {
);
}
// Send start event when resuming the same song that was stopped.
if (
properties.status === PlayerStatus.PLAYING &&
prev.status === PlayerStatus.STOPPED &&
stoppedSongIdRef.current === currentSong._uniqueId
) {
stoppedSongIdRef.current = undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
// Send stop event when status changes to stopped (from an active state)
if (
properties.status === PlayerStatus.STOPPED &&
prev.status !== PlayerStatus.STOPPED
) {
stoppedSongIdRef.current = currentSong._uniqueId;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'stop',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
flushScrobbleDebug();
},
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
+15 -1
View File
@@ -1235,12 +1235,26 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false;
set((state) => {
state.player.status = PlayerStatus.PAUSED;
state.player.status = PlayerStatus.STOPPED;
setTimestampStore(0);
if (reset) {
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
}
});
const currentState = get();
const queue = currentState.getQueue();
const currentIndex = currentState.player.index;
const currentSong = queue.items[currentIndex];
eventEmitter.emit('PLAYER_STOP', {
id: currentSong?._uniqueId,
index:
currentIndex !== undefined && currentIndex >= 0
? currentIndex
: undefined,
reset,
});
},
mediaToggleMute: () => {
set((state) => {
+1
View File
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
export enum PlayerStatus {
PAUSED = 'paused',
PLAYING = 'playing',
STOPPED = 'stopped',
}
export enum PlayerStyle {