Audio & music
Two dedicated modules give widgets live sound and playback state — without shelling out to binaries the user may not have installed.
| Module | What it is | Import |
|---|---|---|
appinapp-audio | System-audio frames for visualizers | import { onAudioFrame } from "appinapp-audio" |
appinapp-music | Now-playing info + playback controls | import { onNowPlaying, getNowPlaying, onPlayers, getPlayers, play, pause, togglePlayPause, next, previous, seek } from "appinapp-music" |
Both are named imports only and go through the same sandbox allowlist as
appinapp-react / appinapp-icons. Anything else throws Import not allowed: <name>.Never shell out tonowplaying-cli(or similar tools) viarun()for track data. Now-playing data comes fromappinapp-musiconly — widgets must not depend on binaries the user may not have installed.
appinapp-audio — visualizer frames
onAudioFrame(cb) subscribes to raw system-audio frames at ~60 Hz. Use it for bars, waveforms, ambient glow — anything that reacts to what is playing.import { onAudioFrame } from "appinapp-audio";
Frame shape
cb receives { spectrum, waveform, rms } on every tick:| Field | Type | Description |
|---|---|---|
spectrum | number[64] | Frequency magnitudes, each ~0–1. Use directly as bar heights. |
waveform | number[512] | Time-domain samples, each ~-1–1. Draw as an oscilloscope line. |
rms | number | Overall loudness (~0–1). Use for glow / scale pulsing. |
Lifecycle
- The first
onAudioFramecall starts capture (Core Audio process tap — no screen-recording indicator). The OS may ask once for audio access. - The callback never fires while permission is denied or nothing is playing — render an idle state, not an error.
- When the last subscriber unsubscribes, capture stops — zero CPU when no visualizer is mounted.
- Returns an unsubscribe function — always return it from
useEffect.
import { useEffect, useRef } from "appinapp-react";
import { onAudioFrame } from "appinapp-audio";
const AudioBars = () => {
const canvasRef = useRef(null);
const frameRef = useRef(null);
useEffect(() => {
const unsubscribe = onAudioFrame((frame) => {
frameRef.current = frame;
});
return () => unsubscribe();
}, []);
return <canvas ref={canvasRef} width={300} height={120} />;
};
export default AudioBars;
Performance rule
Frames arrive at ~60 Hz. Do not
setState on every frame — 60 re-renders per second kills performance. Store the latest frame in a ref and draw from requestAnimationFrame:useEffect(() => {
const unsubscribe = onAudioFrame((frame) => {
frameRef.current = frame; // no setState here
});
let raf = 0;
const draw = () => {
raf = requestAnimationFrame(draw);
const canvas = canvasRef.current;
const frame = frameRef.current;
if (!canvas || !frame) return;
const ctx = canvas.getContext("2d");
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
const bw = width / frame.spectrum.length;
frame.spectrum.forEach((v, i) => {
const h = Math.max(2, v * height);
ctx.fillStyle = "#4ade80";
ctx.fillRect(i * bw + 1, height - h, bw - 2, h);
});
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
unsubscribe();
};
}, []);
rmsis the cheapest way to add life to a static widget — pulse opacity, blur, or scale from it even if you do not draw a full spectrum.
appinapp-music — now playing + controls
import {
onNowPlaying, getNowPlaying,
onPlayers, getPlayers,
play, pause, togglePlayPause,
next, previous, seek,
} from "appinapp-music";
| Function | What it does |
|---|---|
onNowPlaying(cb) | Subscribe to 1 Hz updates while something plays (plus one { playing: false, … } when playback stops). Returns unsubscribe. |
getNowPlaying() | One-shot snapshot of the same payload (artwork included). Prefer onNowPlaying for continuous UI. |
onPlayers(cb) / getPlayers() | Per-app players list (multiple apps at once). Subscribe / one-shot snapshot. |
play(target?), pause(target?), togglePlayPause(target?), next(target?), previous(target?) | Playback controls. Work without subscribing. |
seek(seconds) | Jump to a position in seconds (non-negative number). The new position arrives via onNowPlaying on the next tick. |
Now-playing payload
onNowPlaying / getNowPlaying deliver:{
playing, // boolean
title, artist, album, genre, // strings | null (null when the player omits them)
duration, // seconds | null
elapsedTime, // seconds, extrapolated to now
playbackRate, trackNumber, totalTrackCount,
uniqueIdentifier, bundleIdentifier, processIdentifier,
artwork // { mimeType, dataUrl } | null
}
Notes:
- All metadata fields are nullable — the player decides what it provides.
elapsedTimeis extrapolated to now, so a progress bar stays smooth between 1 Hz ticks.artworkarrives on the first emit and on track change only — cache the last non-null artwork per app instead of clearing the image on every update.
import { useEffect, useState } from "appinapp-react";
import { togglePlayPause, next, onNowPlaying } from "appinapp-music";
const MusicBar = () => {
const [track, setTrack] = useState(null);
const [art, setArt] = useState(null);
useEffect(() => {
return onNowPlaying((info) => {
setTrack(info);
if (info.artwork) setArt(info.artwork); // cache: only sent on track change
});
}, []);
if (!track?.playing) return <div>Nothing playing</div>;
return (
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{art && <img src={art.dataUrl} style={{ width: 40, height: 40 }} />}
<span>{track.title} — {track.artist}</span>
<button onClick={() => togglePlayPause()}>⏯</button>
<button onClick={() => next()}>⏭</button>
</div>
);
};
export default MusicBar;
Players list — multiple apps
The system slot shows one app, but several may hold media sessions (paused video in a tab + background Spotify).
onPlayers(cb) fires whenever the list meaningfully changes (app / track / state flips) and returns an unsubscribe; getPlayers() is the one-shot snapshot.Rows arrive slot-owner-first:
{
bundleIdentifier, processIdentifier,
appName, icon, title, artist, album, artwork,
playing, // live-verified while slot-owned or backed by an audio tap
live, // false = remembered row, may be stale (needs audio permission)
active // true = slot owner — the ONLY row controls act on
}
activemarks the slot owner. Enable buttons only on theactiverow — controls never reach background rows (see below).playingis live-verified while the app owns the slot or a per-process audio tap backs it (this needs system-audio permission — granted via any audio widget). Otherwise the row is remembered withlive: falseand may be stale.artwork/iconarrive on track change only — cache per app like the single-track artwork.
import { useEffect, useState } from "appinapp-react";
import { onPlayers } from "appinapp-music";
const Players = () => {
const [rows, setRows] = useState([]);
useEffect(() => onPlayers(setRows), []);
return (
<div>
{rows.map((r) => (
<div key={r.bundleIdentifier || r.processIdentifier}>
{r.appName}: {r.title} {r.playing ? "▶" : "⏸"}
{r.live ? "" : " (remembered)"}
</div>
))}
</div>
);
};
export default Players;
Playback controls — slot-only delivery
play(target?), pause(target?), togglePlayPause(target?), next(target?), previous(target?) — target is a bundle id string (e.g. "com.spotify.client") or a pid number. Each resolves to { ok, reason, appliedTo }:- The target owns the slot → sent directly (
{ ok: true, reason: "sent" }). - A known background player with AppleScript support (Spotify and Music.app only) → fixed
osascript(sent). - Anything else is refused without sending — a command can never silently control a different app than the one pressed.
reason | Meaning |
|---|---|
"sent" | Command delivered |
"not_controllable" | Known player, but does not own the slot |
"unknown_target" | No such player seen |
"no_player" | Slot unreadable |
"automation_denied" / "automation_timeout" | System blocked the script |
Omitted
target keeps the historical blind-send-to-slot behaviour. There is no way to move slot ownership to a background app, and Chrome / Safari / Telegram expose no control surface at all.The first scripted command pops a system Automation prompt ("would like to control …"). Until approved, background Spotify / Music control fails closed.
Audio + music together
The two modules compose naturally — visualizer from
appinapp-audio, identity + transport from appinapp-music. They subscribe independently, so mount each only while its UI is visible.import { useEffect, useRef, useState } from "appinapp-react";
import { onAudioFrame } from "appinapp-audio";
import { onNowPlaying, togglePlayPause } from "appinapp-music";
const NowPlayingVisualizer = () => {
const [track, setTrack] = useState(null);
const frameRef = useRef(null);
useEffect(() => onNowPlaying(setTrack), []);
useEffect(() => onAudioFrame((f) => { frameRef.current = f; }), []);
if (!track?.playing) return <div>Nothing playing</div>;
return (
<div>
<div>{track.title} — {track.artist}</div>
<button onClick={() => togglePlayPause()}>⏯</button>
{/* draw frameRef.current.spectrum to canvas via rAF */}
</div>
);
};
export default NowPlayingVisualizer;
Checklist
- Named imports only —
import { onAudioFrame } from "appinapp-audio", never a default import orwindow.*. - Cleanup — always return the unsubscribe from
useEffectforonAudioFrame,onNowPlaying, andonPlayers. - No 60 Hz
setState— stash audio frames in a ref, draw withrequestAnimationFrame. - Cache artwork —
artwork/iconare sent on track change only; keep the last non-null value per app. - Gate controls on
active— only the slot-owner row is controllable; everything else stays disabled. - Handle silence —
onAudioFramestays quiet when denied / idle;onNowPlayingemits oneplaying: falsethen stays quiet. Render idle UI, not errors. - No
nowplaying-cli— all playback state comes fromappinapp-music.