Skip to main content

Function: useVisualizer()

function useVisualizer(
player: Player | null | undefined,
options?: VisualizerOptions,
enabled?: boolean,
pauseWhenInactive?: boolean
): UseVisualizerResult;

Defined in: packages/player/src/hooks/useVisualizer.ts:146

Subscribe a component to the player's visualizer for as long as it is mounted.

Parameters

ParameterTypeDefault valueDescription
playerPlayer | null | undefinedundefinedThe player to tap, or undefined/null while one is still being created (the hook then does nothing, which keeps it usable directly with usePlayer()'s result).
options?VisualizerOptionsundefinedPer-subscriber tuning; see VisualizerOptions.
enabled?booleantrueSet false to drop the subscription without unmounting — e.g. while the visualizer is off screen. Defaults to true.
pauseWhenInactive?booleantrueDrop the subscription whenever the app leaves the foreground or the device's display goes off, and take it back when both are true again. Defaults to true, and should stay that way; see below.

Returns

UseVisualizerResult

The newest VisualizerFrame, plus any typed error.

Remarks

This re-renders at the frame rate (up to options.fps, itself capped by the device's ~20 Hz). That is the point of the hook, but it means the component using it should be a small leaf that paints bars and nothing else. For anything heavier, subscribe imperatively with player.visualizer.subscribe() and drive an animated value instead of React state.

Unsubscribing is what releases the platform effect, so unmounting (or flipping enabled to false) genuinely returns the audio framework to its idle state — there is no hidden capture left running.

Why pauseWhenInactive defaults to on. The frames are native callbacks, so unlike a JS timer they do not freeze when the app goes to the background — the sampler thread keeps delivering and this hook keeps calling setState, at up to 60 Hz, against a display that cannot present anything. Left alone through a long screen-off that is tens of thousands of pointless renders and a matching amount of CPU and battery spent drawing to nobody, and the app is busy working through them at the moment the user unlocks. Pausing costs nothing while visible and removes the whole class of problem: because the native tap is derived from the listener set, dropping the subscription disarms mpv's ring and stops the sampler thread outright.

Why the gate is two signals, not one. AppState alone is not enough on Android, and the failure is not theoretical: a screen-off soak on a Poco F4 (MIUI, charging) recorded AppState reporting the app foreground again while the display stayed off — subscribed 11:25, paused 11:36, re-subscribed 11:43 with the screen still off, paused 11:53 — and the visualizer burned 65-80 % of a core in the window it was wrongly awake. So the hook ANDs AppState with the platform's own display state (ScreenStateSourcePowerManager.isInteractive() + ACTION_SCREEN_ON/OFF on Android): either signal saying "inactive" pauses, and both must say "active" to resume. On iOS the second signal is a constant true and that is correct — locking an iPhone resigns the app's active state and backgrounds it, and there is no iOS state where the app is foreground-active with the display off, so AppState is already the display truth there.

Audio is untouched by this. Only the visual feed pauses; playback, the media session and everything else keep running in the background exactly as before.

On resume the same options are used, so nothing has to be re-plumbed — but the subscription is a new one, which means smoothing, peak caps and auto-gain start from rest and UseVisualizerResult.frame is undefined for a frame or two. Bars come back up from zero rather than continuing mid-bounce.

Set it to false only for a surface that is genuinely painting while inactive (a Live Activity, a widget, an external display). For a non-UI consumer, the escape hatch is player.visualizer.subscribe() directly: the imperative API is never AppState-gated, and never has been.

Example

function Bars({ player }: { player: Player }) {
const { frame, error } = useVisualizer(player, { bands: 24 })
if (error) return <Text>{error.message}</Text>
return (
<View style={{ flexDirection: 'row' }}>
{Array.from(frame?.bands ?? []).map((value, i) => (
<View key={i} style={{ height: 4 + value * 60, width: 6 }} />
))}
</View>
)
}