Files
feishin/src/renderer/features/player/hooks/use-media-session.ts
T
Auzlex 895cbb4d16 fix(media-session): prevent handlers from being destroyed during playback (#1898)
Handlers were being registered and destroyed on state change/re-render,
causing media controls to vanish during rapid use or quick track skipping.
Persist handlers and add debounce for rapid track skipping.

Tested on Windows, Linux, and Android.
2026-04-05 22:27:04 -07:00

308 lines
11 KiB
TypeScript

import isElectron from 'is-electron';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import {
subscribeCurrentTrack,
subscribePlayerStatus,
usePlaybackSettings,
usePlayerStore,
useSettingsStore,
useSkipButtons,
useTimestampStoreBase,
} from '/@/renderer/store';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const mediaSession = navigator.mediaSession;
export const useMediaSession = () => {
const { mediaSession: mediaSessionEnabled } = usePlaybackSettings();
const player = usePlayer();
const skip = useSkipButtons();
const playbackType = useSettingsStore((state) => state.playback.type);
const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();
// Keep refs to current values to avoid dependency changes triggering handler re-registration
const playerRef = useRef(player);
const skipRef = useRef(skip);
const isRadioActiveRef = useRef(isRadioActive);
const isRadioPlayingRef = useRef(isRadioPlaying);
const radioMetadataRef = useRef(radioMetadata);
const stationNameRef = useRef(stationName);
const isMediaSessionEnabledRef = useRef(false);
// Update refs whenever values change, but don't trigger effects
useEffect(() => {
playerRef.current = player;
}, [player]);
useEffect(() => {
skipRef.current = skip;
}, [skip]);
useEffect(() => {
isRadioActiveRef.current = isRadioActive;
}, [isRadioActive]);
useEffect(() => {
isRadioPlayingRef.current = isRadioPlaying;
}, [isRadioPlaying]);
useEffect(() => {
radioMetadataRef.current = radioMetadata;
}, [radioMetadata]);
useEffect(() => {
stationNameRef.current = stationName;
}, [stationName]);
const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web
if (!isElectron()) {
return true;
}
return Boolean(mediaSessionEnabled && playbackType === PlayerType.WEB);
}, [mediaSessionEnabled, playbackType]);
useEffect(() => {
isMediaSessionEnabledRef.current = isMediaSessionEnabled;
}, [isMediaSessionEnabled]);
// Register/unregister handlers whenever isMediaSessionEnabled changes so that
// enabling the setting after mount correctly registers handlers instead of
// silently no-oping because the [] effect already ran.
useEffect(() => {
if (!isMediaSessionEnabled) {
mediaSession.setActionHandler('nexttrack', null);
mediaSession.setActionHandler('pause', null);
mediaSession.setActionHandler('play', null);
mediaSession.setActionHandler('previoustrack', null);
mediaSession.setActionHandler('seekto', null);
mediaSession.setActionHandler('stop', null);
mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null);
return;
}
mediaSession.setActionHandler('nexttrack', () => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
playerRef.current.mediaNext();
});
mediaSession.setActionHandler('pause', () => {
playerRef.current.mediaPause();
});
mediaSession.setActionHandler('play', () => {
playerRef.current.mediaPlay();
});
mediaSession.setActionHandler('previoustrack', () => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
playerRef.current.mediaPrevious();
});
mediaSession.setActionHandler('seekto', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
if (e.seekTime) {
playerRef.current.mediaSeekToTimestamp(e.seekTime);
} else if (e.seekOffset) {
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);
}
});
mediaSession.setActionHandler('stop', () => {
playerRef.current.mediaStop();
});
mediaSession.setActionHandler('seekbackward', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp(
currentTimestamp - (e.seekOffset || skipRef.current?.skipBackwardSeconds || 5),
);
});
mediaSession.setActionHandler('seekforward', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp(
currentTimestamp + (e.seekOffset || skipRef.current?.skipForwardSeconds || 5),
);
});
return () => {
mediaSession.setActionHandler('nexttrack', null);
mediaSession.setActionHandler('pause', null);
mediaSession.setActionHandler('play', null);
mediaSession.setActionHandler('previoustrack', null);
mediaSession.setActionHandler('seekto', null);
mediaSession.setActionHandler('stop', null);
mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null);
};
}, [isMediaSessionEnabled]);
const updateMediaSessionMetadata = useCallback(
(song: QueueSong | undefined) => {
// Read from ref so this callback is never stale regardless of when it was created
if (!isMediaSessionEnabledRef.current) {
return;
}
// Handle radio metadata when radio is active and playing
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
const title = radioMetadataRef.current?.title || stationNameRef.current || 'Radio';
const artist = radioMetadataRef.current?.artist || stationNameRef.current || '';
mediaSession.metadata = new MediaMetadata({
album: stationNameRef.current || '',
artist: artist,
artwork: [],
title: title,
});
return;
}
// Handle regular song metadata
if (!song) {
return;
}
const imageUrl = getItemImageUrl({
id: song?.imageId || undefined,
imageUrl: song?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
mediaSession.metadata = new MediaMetadata({
album: song?.album ?? '',
artist: song?.artistName ?? '',
artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],
title: song?.name ?? '',
});
},
// All values are read from refs — stable callback, no stale closure risk
[],
);
// Debounced version to handle rapid skipping — only the last skip in a burst commits
// to the media session. Without this, rapid MediaMetadata assignments can tear the
// browser's media session state and permanently drop the handlers.
const debouncedUpdateMetadata = useRef(
debounce((song: QueueSong | undefined) => {
updateMediaSessionMetadata(song);
}, 100),
).current;
// Cancel any pending debounced update on unmount
useEffect(() => {
return () => {
debouncedUpdateMetadata.cancel();
};
}, [debouncedUpdateMetadata]);
// Update metadata when radio metadata changes
useEffect(() => {
if (!isMediaSessionEnabled) {
return;
}
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
debouncedUpdateMetadata(undefined);
}
}, [radioMetadata, isRadioPlaying, isMediaSessionEnabled, debouncedUpdateMetadata]);
// Subscribe directly to the player store instead of using usePlayerEvents.
// usePlayerEvents receives inline handler objects that cause it to re-subscribe on every
// render, which destroys and recreates the media session on play/pause and track changes.
// subscribeCurrentTrack and subscribePlayerStatus are stable Zustand subscriptions with
// proper equality checks — registered once on mount and never torn down mid-session.
useEffect(() => {
const unsubscribeCurrentSong = subscribeCurrentTrack(({ song }) => {
if (!isMediaSessionEnabledRef.current) {
return;
}
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
debouncedUpdateMetadata(song);
});
const unsubscribeStatus = subscribePlayerStatus(({ status }) => {
if (!isMediaSessionEnabledRef.current) {
return;
}
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
});
return () => {
unsubscribeCurrentSong();
unsubscribeStatus();
};
}, [debouncedUpdateMetadata]);
// onPlayerRepeated fires via eventEmitter (not Zustand), so usePlayerEvents is safe here —
// the event emitter uses stable function references for on/off and does not re-subscribe
// on render. The inline object is fine because deps is [] and the effect only runs once.
usePlayerEvents(
{
onPlayerRepeated: () => {
if (!isMediaSessionEnabledRef.current) {
return;
}
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentSong = usePlayerStore.getState().getCurrentSong();
debouncedUpdateMetadata(currentSong);
},
},
[],
);
};
const MediaSessionHookInner = () => {
useMediaSession();
return null;
};
export const MediaSessionHook = () => {
// Always render the hook — let the internal guard logic decide whether to act.
// Conditional rendering here causes unmount/remount cycles that destroy handlers mid-session.
return React.createElement(MediaSessionHookInner);
};