From 37ada07ee2cb0d8c105979587a5a7b063042acf1 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 29 Jun 2026 20:23:46 -0700 Subject: [PATCH] add "stopped" playback state and event handlers --- src/main/features/linux/mpris.ts | 7 +- src/remote/components/remote-container.tsx | 2 +- src/renderer/events/events.ts | 7 ++ .../audio-player/engine/mpv-player-engine.tsx | 2 +- .../audio-player/hooks/use-player-events.ts | 8 +++ .../player/audio-player/mpv-player.tsx | 22 +++--- .../player/audio-player/wavesurfer-player.tsx | 14 ++-- .../player/audio-player/web-player.tsx | 30 ++++---- .../player/components/center-controls.tsx | 2 +- .../mobile-fullscreen-player-controls.tsx | 2 +- .../player/components/mobile-playerbar.tsx | 2 +- .../features/player/hooks/use-scrobble.ts | 72 +++++++++++++++++++ src/renderer/store/player.store.ts | 16 ++++- src/shared/types/types.ts | 1 + 14 files changed, 147 insertions(+), 40 deletions(-) diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts index f79417c6c..e1e1940d2 100644 --- a/src/main/features/linux/mpris.ts +++ b/src/main/features/linux/mpris.ts @@ -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 = { diff --git a/src/remote/components/remote-container.tsx b/src/remote/components/remote-container.tsx index 4566cd82e..3d73208f3 100644 --- a/src/remote/components/remote-container.tsx +++ b/src/remote/components/remote-container.tsx @@ -128,7 +128,7 @@ export const RemoteContainer = () => { onClick={() => { if (status === PlayerStatus.PLAYING) { send({ event: 'pause' }); - } else if (status === PlayerStatus.PAUSED) { + } else { send({ event: 'play' }); } }} diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index 6879595f0..875a81d96 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -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[]; 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 index 630170f88..8cf355644 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -212,7 +212,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { if (playerStatus === PlayerStatus.PLAYING) { mpvPlayer.play(); - } else if (playerStatus === PlayerStatus.PAUSED) { + } else { mpvPlayer.pause(); } }, [playerStatus]); diff --git a/src/renderer/features/player/audio-player/hooks/use-player-events.ts b/src/renderer/features/player/audio-player/hooks/use-player-events.ts index 15fda73bf..806def537 100644 --- a/src/renderer/features/player/audio-player/hooks/use-player-events.ts +++ b/src/renderer/features/player/audio-player/hooks/use-player-events.ts @@ -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); } diff --git a/src/renderer/features/player/audio-player/mpv-player.tsx b/src/renderer/features/player/audio-player/mpv-player.tsx index 362ec406a..ebf469b72 100644 --- a/src/renderer/features/player/audio-player/mpv-player.tsx +++ b/src/renderer/features/player/audio-player/mpv-player.tsx @@ -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); } } }, diff --git a/src/renderer/features/player/audio-player/wavesurfer-player.tsx b/src/renderer/features/player/audio-player/wavesurfer-player.tsx index c8d2be96e..5d56a3e6d 100644 --- a/src/renderer/features/player/audio-player/wavesurfer-player.tsx +++ b/src/renderer/features/player/audio-player/wavesurfer-player.tsx @@ -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) => { diff --git a/src/renderer/features/player/audio-player/web-player.tsx b/src/renderer/features/player/audio-player/web-player.tsx index 7add679a0..ead7bd114 100644 --- a/src/renderer/features/player/audio-player/web-player.tsx +++ b/src/renderer/features/player/audio-player/web-player.tsx @@ -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); } } }, diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index 92dac5045..ba57a935d 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -203,7 +203,7 @@ const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => { return ( ); diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx index 6a3505c3a..5877c9251 100644 --- a/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx +++ b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx @@ -51,7 +51,7 @@ export const MobileFullscreenPlayerControls = memo( /> { /> { e.stopPropagation(); mediaTogglePlayPause(); diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index 29e42578b..5103b9bc8 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -131,6 +131,7 @@ export const useScrobble = () => { const previousSongRef = useRef(undefined); const previousTimestampRef = useRef(0); const stopPositionRef = useRef(0); + const stoppedSongIdRef = useRef(undefined); const lastProgressEventRef = useRef(0); const lastSeekEventRef = useRef(0); const songChangeTimeoutRef = useRef | 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], diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index d0b26e4c7..40514aa60 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -1235,12 +1235,26 @@ export const usePlayerStoreBase = createWithEqualityFn()( 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) => { diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 5c4054a0e..29dc5dfc4 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -149,6 +149,7 @@ export enum PlayerShuffle { export enum PlayerStatus { PAUSED = 'paused', PLAYING = 'playing', + STOPPED = 'stopped', } export enum PlayerStyle {