The window.amee SDK Reference
Exposed as a page global (window.amee), not something you import — skins are loaded as dynamically-constructed blob-URL ES modules, and bare package imports don’t resolve from a blob URL without extra setup. One small, documented global is simpler than requiring one.
| Member | Signature | Notes |
|---|---|---|
version |
string |
SDK contract version (currently "1"). Bumped only on a breaking change to this table. |
getNowPlaying() |
() => NowPlaying | null |
Last-known now-playing state; null before the first update arrives or when nothing’s playing. |
onNowPlaying(cb) |
(cb: (np: NowPlaying | null) => void) => () => void |
Subscribe to changes; call the returned function to unsubscribe. |
play() / pause() / togglePlay() / next() / previous() |
() => Promise<void> |
Playback transport controls. |
seek(seconds) |
(seconds: number) => Promise<void> |
Seeks to an absolute position in the current track. getNowPlaying().elapsed_seconds reports the requested position immediately and keeps ticking from it, rather than reverting to the old one until the player reports back — so a seek bar bound straight to that field doesn’t snap backwards for a frame. The hold is dropped once the player agrees, on a track change, or after 3s if the seek was refused (an ad, a live stream). |
openNowPlayingApp() |
() => Promise<void> |
Brings the app actually playing the current track to the foreground (Chrome, Music, Spotify, whatever getNowPlaying().bundle_identifier reports) — launches it if it isn’t running. Rejects if nothing’s currently playing. |
focusNowPlayingTab() |
() => Promise<void> |
Raises the exact browser tab producing the audio, not just the browser — openNowPlayingApp() can only bring the browser forward on whatever tab happened to be active, because the OS session names an app rather than a document. Requires the Amee browser extension; rejects when it isn’t the current source. |
exitPictureInPicture() |
() => Promise<void> |
Closes the browser’s Picture-in-Picture window, returning the video to its tab — the PiP window’s own “Back to tab” button minus the “go to the tab” half, which is focusNowPlayingTab(). Gate on nowPlaying.source === "extension" && nowPlaying.picture_in_picture, not on PipDockStatus (which additionally requires docking to be configured). Only ever closes the window the mini player is showing. There is no matching “open”: entering PiP needs a real user gesture inside the page, leaving does not. |
isBrowserMediaAvailable() |
() => Promise<boolean> |
Whether a browser tab is currently the now-playing source via the extension — i.e. whether focusNowPlayingTab() and NowPlaying.url have anything to offer right now. false whenever the extension isn’t installed, which is the common case. Gate your affordance on this instead of showing a button that usually fails. |
toggleNowPlayingApp() |
() => Promise<void> |
Like openNowPlayingApp(), but toggles: if the now-playing app is already frontmost, jumps back to whatever app the user was in before. Otherwise remembers the current frontmost app and opens the now-playing app. If the user manually switches to some other app in between, the next call just opens the now-playing app again — the previously remembered app is forgotten (unless “Always return to the app I toggled from” is on in Settings). Rejects if nothing’s currently playing. |
getVolume() |
() => Promise<number> |
System output volume, 0–1. |
setVolume(v) |
(v: number) => Promise<void> |
Clamped to 0–1 internally. |
getMuted() / setMuted(v) |
() => Promise<boolean> / (v: boolean) => Promise<void> |
System mute state. |
onVolumeChange(cb) |
(cb: (state: {volume, muted}) => void) => () => void |
Fires for any change — your slider, physical volume keys, Control Center, another app. |
getOutputDevices() |
() => Promise<OutputDevice[]> |
Lists output-capable Core Audio devices — built-in speakers, USB, Bluetooth, AirPlay, HDMI. AirPlay/Bluetooth speakers show up here like any other device once connected — see the gotcha below for why a separate AirPlay button API exists anyway. |
setOutputDevice(id) |
(id: string) => Promise<void> |
Switches the system default output device. Rejects if id no longer refers to a connected device. |
onOutputDevicesChange(cb) |
(cb: (devices: OutputDevice[]) => void) => () => void |
Fires on any change to the list or the current default — plug/unplug, Bluetooth connect/disconnect, or the user switching outputs in System Settings/Control Center. |
attachAirPlayButton(element) |
(element: Element) => () => void |
Makes element’s rect the hit-target for Apple’s real AirPlay picker popover — see the gotcha below. Renders Apple’s own AirPlay glyph directly on element (already reflects active-route state on its own) — size/position an empty element as your button and this fills it in. Re-syncs to element’s rect on resize; call the returned cleanup function when done with it (e.g. on unmount). Only one native hit-target exists per mini-player window — a second call for a different element just moves it there. |
onSpectrum(cb) |
(cb: (bins: number[]) => void) => () => void |
Same FFT magnitude data the built-in visualizer draws from, if you want to build your own. No events fire until startVisualizer() has been called and resolved. |
startVisualizer() |
() => Promise<void> |
Enables the system audio tap that feeds onSpectrum — triggers a macOS system-audio-recording permission prompt the first time it’s called. Rejects if the permission is denied or the tap fails to start. |
stopVisualizer() |
() => Promise<void> |
Stops the tap started by startVisualizer(). Safe to call even if it’s not running. |
onResize(cb) |
(cb: (size: {width, height}) => void) => () => void |
Fires immediately with the current content-area size (CSS px), then again on every resize. A no-op stream (fires once, never again) for a skin whose manifest isn’t resizable: true. |
getToken(name) |
(name: string) => string |
Reads a theme CSS custom property’s current resolved value, e.g. getToken("--accent") → "#8b7cff". Lets a skin adapt to whatever theme the user has chosen. |
startWindowDrag() |
() => void |
Starts an OS-level window drag. Call from a mousedown handler on whichever part of your markup should double as a drag handle — Amee doesn’t impose a fixed drag region, you decide what’s draggable. |
activateWindow() |
() => Promise<void> |
Requests real OS-level focus for the mini-player window — activates Amee (foreground/Dock) and steals keyboard focus from whatever app the user was using. A system-wide side effect unlike anything else in this table — see the gotcha below before calling it from hover. |
onWindowFocusChange(cb) |
(cb: (focused: boolean) => void) => () => void |
Fires whenever the mini-player window’s own OS focus state changes — true on gaining real focus, false the moment it moves to another window/app — a false needs no preceding true, and usually won’t have one, since the mini-player is a non-activating panel that declines key status for plain clicks. Both another app becoming frontmost and any mouse press outside the mini-player’s interactive content report false, so a click on another window of an already-frontmost app dismisses too. The generic fix for any skin-drawn popover (a dropdown menu, an open panel): there’s no DOM blur/focus event for “the user clicked into some other app,” so without this a popover left open just stays open forever once Amee loses focus. Close it yourself on false. |
openSettings() |
() => Promise<void> |
Shows and focuses Amee’s settings/dashboard window — the same window the tray menu’s “Settings…” item opens. It runs hidden in the background otherwise, so this is the only way for a skin to reach it. |
checkForUpdate() |
() => Promise<UpdateCheckResult> |
Checks whether a newer Amee release is published. Returns { available, currentVersion, latestVersion, notes }. Unlike the Settings banner, ignores any version the user clicked “Skip this version” for, so it can report available: true for a release Settings is suppressing. Doesn’t download or install anything — pair a positive result with openSettings() so the user can update from there. Rejects on network/signature failure. |
quit() |
() => Promise<void> |
Quits Amee entirely — same effect as the tray menu’s “Quit Amee” item. No confirmation prompt; the app closes immediately once this resolves (subject to graceful shutdown if your manifest opts in). |
hide() |
() => Promise<void> |
Hides the mini-player window without quitting Amee — same effect as the tray icon’s manual hide. Bring it back via the tray icon or activateWindow(). |
onShutdown(cb) |
(cb: () => void | Promise<void>) => void |
Registers a callback to run when Amee is about to quit — see Graceful shutdown. Only fires if your manifest sets graceful_shutdown: true; best-effort, bounded by graceful_shutdown_timeout_ms. |
onShow(cb) |
(cb: () => void) => () => void |
Fires when the mini-player transitions from hidden to visible — auto-hide un-hiding after playback resumes, or the user clicking the tray icon to bring it back. Doesn’t fire on a redundant show while already visible. Use it for a re-entrance flourish (replaying your opening animation, say). |
onHide(cb) |
(cb: () => void | Promise<void>) => () => void |
Fires right before the mini-player is actually hidden (from hide() or auto-hide). The window waits for every registered callback to settle before hiding — use it to prime your UI back to a “ready to reappear” state. Best-effort, bounded to a couple hundred ms on the Rust side. |
isFnKeyHeld() |
() => Promise<boolean> |
Whether the physical Fn key is currently held down. macOS only (rejects on other platforms). Fn has no web-platform representation, so this is backed by a native monitor. |
onFnKeyChange(cb) |
(cb: (held: boolean) => void) => () => void |
Fires every time the physical Fn key is pressed or released. macOS only (never fires on other platforms). Useful for modifier-held alternate actions — e.g. the classic skin’s menu shows “Quit” while Fn is held, “Hide” otherwise. |
getDominantColors(count?) |
(count?: number) => Promise<string[]> |
Analyzes the current track’s album artwork and returns its dominant color(s) as "#rrggbb" hex strings, most dominant first. count defaults to 1. Rejects if no artwork is currently available. |
getSkinAsset(path) |
(path: string) => Promise<string> |
Resolves a file bundled in your own package (relative to its root) to a ready-to-use data: URI, e.g. img.src = await amee.getSkinAsset("cover.png"). A path that tries to escape the package is rejected. |
openSkinWindow(entry, options?) |
(entry: string, options?: SkinWindowOptions) => Promise<void> |
Opens a normal, decorated window running any other entry file from your own package — see Extra windows and storage. Calling it again with the same entry focuses the existing window instead of opening a duplicate. |
storage.get(key) / storage.set(key, value) |
(key: string) => Promise<T | undefined> / (key: string, value: unknown) => Promise<void> |
Free-form per-skin key-value storage. |
storage.onChange(cb) |
(cb: (key: string) => void) => () => void |
Fires whenever storage.set() is called for your skin from any of its windows, including this one. |
expandWindowFlyout(extraLogicalPx) |
(extraLogicalPx: number) => Promise<{direction: "up" | "down", extra: number}> |
Temporarily grows the (undecorated, fixed-size) mini-player window by extraLogicalPx so a popover you draw can pop out above/below your own UI instead of being clipped by the window frame. direction is whichever side actually had room on the current monitor; extra is how many logical px were actually applied (clamped to a sane range). Idempotent while already expanded — collapse first if you need to request a different size. Only resolves once the native move/resize has actually landed — see the gotcha below. If you know upfront how much room your popover will ever need, prefer declaring content_height in your manifest instead — no window move/resize, no round trip, no gotcha. |
collapseWindowFlyout() |
() => Promise<void> |
Restores the geometry expandWindowFlyout() changed. Safe to call even when nothing is currently expanded. |
reportContentExtent(above, below) |
(extraAbove: number, extraBelow: number) => Promise<void> |
If your manifest declares content_height, tells Amee how much of the reserved dead-space band above/below your content your UI is currently actually occupying (0 when idle). Report the real extent whenever it changes (e.g. on hover-in/hover-out) or clicks on the visible part of your popover in that band get swallowed as click-through. No-op for a skin without content_height. |
setPipDock(dock) |
(dock: PipDock | null) => Promise<void> |
Overrides which edge of the browser’s Picture-in-Picture window this skin docks to, merging field by field over manifest.json. null restores the manifest. Declare your resting dock in the manifest — this call is a round trip late, so use it only when your skin changes modes. |
getPipDock() |
() => Promise<PipDockStatus> |
Whether the mini player is attached to a Picture-in-Picture window right now, and to which edge. Read it before opening anything that grows past your content: docked below a video, the space above your content is on top of the video. |
dockToPip() |
() => Promise<void> |
Re-attaches the mini player to the Picture-in-Picture window and snaps it flush. Rejects when availability isn’t "ok", when no video is popped out, or when your manifest opted out with pip_dock_edge: "none". Gate the button on the status instead of relying on the rejection. |
PipDockStatus |
{ availability, browser_signal, pip_open, docked, edge, align, gap, snapping } |
availability is "ok" | "disabled" | "integration_off" | "no_extension" — whether docking is configured to work, regardless of whether a video is open; treat an unknown value as not usable. browser_signal is the same enum minus "disabled": whether browser-sourced information can reach Amee at all, ignoring the user’s docking switch. Read it when you care about the plumbing rather than about docking — with docking off, availability only ever says "disabled" and hides whether the extension is even connected. pip_open is whether a PiP window is open right now; it stays true after the user drags the pill away, which is when a re-attach button is worth showing. |
onPipDockChange(cb) |
(cb: (status: PipDockStatus) => void) => () => void |
Fires when that answer changes — a video popped out or closed, the user dragged the pill away, or a drag crossed the re-attach threshold. Render something while status.snapping is true: docking is magnetic and otherwise gives no feedback until after the pointer is up. Returns an unsubscribe function. |
onPointerMove(cb) |
(cb: (pos: {x, y} | null) => void) => () => void |
Cursor position in this window’s own DOM coordinate space (same units as MouseEvent.clientX/clientY), or null once it’s left the window — keeps firing regardless of whether Amee is the focused app. See the gotcha below on why you’d reach for this instead of native hover events. Prefer trackHover for the common case. |
trackHover(element, opts?) |
(element: Element, opts?: {className?, onEnter?, onLeave?}) => () => void |
:hover-alike built on onPointerMove that keeps working while Amee is in the background. Toggles opts.className (default "amee-hover") on element based on its getBoundingClientRect(), and calls opts.onEnter/opts.onLeave at the same transitions. Returns a cleanup function. |
NowPlaying is { title, artist, album, artwork_data_uri, playing, elapsed_seconds, duration_seconds, bundle_identifier, source, url, artwork_url, picture_in_picture, tab_id } (all nullable except playing, picture_in_picture and source).
Two sources can produce it, and source says which:
"media_remote"— the OS now-playing session. Covers every app: Spotify, Music, VLC, and browsers most of the time.url,artwork_urlandtab_idare alwaysnullhere; the OS session identifies an app, and carries no URL at all."extension"— the Amee browser extension. Only source that can see browser media once a video is popped out into Picture-in-Picture: entering PiP makes the browser drop its OS session entirely, so without the extension a popped-out video reads as “nothing playing”.
Everything a skin already reads keeps working on both paths — including artwork_data_uri, which Amee fills in on the extension path by fetching the artwork the page declared. Treat url and tab_id as bonuses that are usually absent: most users won’t have the extension installed. Gate any affordance on isBrowserMediaAvailable() rather than assuming.
OutputDevice is { id, name, kind, is_default }, where id is a stable Core Audio device UID string (not a raw device index — pass it back verbatim to setOutputDevice) and kind is one of "builtin" | "usb" | "bluetooth" | "bluetoothLE" | "airplay" | "hdmi" | "other".
UpdateCheckResult is { available, currentVersion, latestVersion, notes } — available is boolean, currentVersion is the running build’s version string (e.g. "1.4.0"), latestVersion and notes are string | null (null when no update is available).
A gotcha: getOutputDevices() can’t see an AirPlay speaker that’s never been selected
Section titled “A gotcha: getOutputDevices() can’t see an AirPlay speaker that’s never been selected”getOutputDevices() enumerates real Core Audio device objects — but macOS only creates one for an AirPlay speaker once it’s been selected as the output at least once, and tears it down again the moment the user switches away from it. Until then, it simply doesn’t exist as far as any Core Audio API (this one included) can see, no matter how you poll. That’s not a bug here; it’s how AirPlay routing on macOS actually works — Control Center’s own AirPlay icon works around it by not using Core Audio enumeration at all, instead relying on Apple’s separate, non-queryable AirPlay route-discovery mechanism exposed only through a real, clickable native button (AVRoutePickerView).
amee.attachAirPlayButton(element) is that button, positioned over element (rendering Apple’s own AirPlay glyph there — it already reflects active-route state on its own), so a real click there opens Apple’s picker showing every AirPlay receiver currently discoverable — not just ones getOutputDevices() already knows about. getOutputDevices()/onOutputDevicesChange are still useful alongside it for anything else your skin wants to know (e.g. listing non-AirPlay outputs), just not for drawing this particular icon.
A gotcha: don’t use native :hover in a skin at all
Section titled “A gotcha: don’t use native :hover in a skin at all”Native CSS :hover (and the mouseenter/mouseleave/mouseover/mouseout DOM events that drive it) is unusable in the mini-player window, in two different ways:
- It doesn’t update while Amee is unfocused. macOS doesn’t run hit-testing for a window that isn’t key, so the moment the user clicks into some other app, the mini player — despite still being the topmost, always-on-top window on screen — stops seeing real hover input entirely.
- It freezes instead of clearing, even while Amee is focused. The dead-space band around your content is click-through, which Amee implements by turning the window’s mouse events off entirely while the cursor is in that band. The webview then receives no further mouse events, so WebKit never gets the
mouseExitedthat would drop:hover— and the cursor has to cross that band to leave the window at all. Whatever was hovered stays hovered: a knob stuck at full size, a tooltip stranded on screen with stale text, a flyout left open over whatever the user switched to, until the next click anywhere forces a re-hit-test.
Neither is an Amee bug to work around per-skin; both fall out of what a floating always-on-top click-through window is. So the SDK solves it once: amee.onPointerMove tracks the cursor natively regardless of focus, and amee.trackHover wraps it as a drop-in :hover replacement that reports the leave correctly in every case above.
So key your hover rules off trackHover’s class instead of :hover, not alongside it — a :hover left in the selector list is exactly what freezes. And track and toggle the class on the element that actually grows, not a static ancestor it grows past:
/* Not `.my-popover:hover, .my-popover.amee-hover` — the `:hover` half sticks. */.my-popover.amee-hover { /* ... */}amee.trackHover(myPopover, { onEnter: () => {/* same as your old "mouseenter" handler */}, onLeave: () => {/* same as your old "mouseleave" handler */},});trackHover hit-tests via plain getBoundingClientRect() — unlike native :hover, which bubbles from a hovered descendant up to every ancestor regardless of the ancestor’s own box. If your popover is a position: absolute child that visually grows past a smaller, non-resizing wrapper, and you track the wrapper instead of the popover itself, you’ll get a spurious onLeave the instant the cursor moves off the wrapper’s small box and onto the still-visible popover. Track the popover element directly instead.
See the bundled classic skin’s volume flyout (main.js/App.css) for a complete example — it tracks hover on the capsule that actually expands, not the fixed-size row it sits in. :focus-within/document.activeElement aren’t affected by any of this — keyboard focus state is plain DOM state, not something that depends on real-time hit-testing.
trackHover alone already fixes hover visually without any focus cost — reach for amee.activateWindow() (below) on top of it only when a hovered control should also be genuinely interactive while Amee is unfocused (real keyboard input, a slider that should drag like a normal focused control), and you’re knowingly accepting the trade-off described in the next gotcha.
A gotcha: activateWindow() steals focus system-wide
Section titled “A gotcha: activateWindow() steals focus system-wide”amee.activateWindow() requests real OS-level focus for the mini-player window. Amee is a regular Dock app (no accessory/menu-bar activation policy), so under the hood this is AppKit’s makeKeyAndOrderFront: — the same call any other app window’s focus request would make. That means calling it doesn’t just make the mini player’s own webview more responsive, it activates Amee: brings it to the foreground/Dock and takes keyboard focus away from whatever app the user was just using, exactly as if they’d clicked Amee’s Dock icon.
Calling it from hover (rather than gating it behind a click/drag-start) means that focus-steal can happen from mere mouse movement, with no click involved — weigh that before doing it. The bundled classic skin’s volume flyout pairs trackHover’s onEnter with amee.activateWindow() so the slider that pops open is immediately a fully-focused, normally-draggable control, and accepts the focus-steal as the cost of that. If your hover effect is purely visual (a tooltip, a reveal-on-hover button) you don’t need activateWindow() at all.
A gotcha: expandWindowFlyout’s completion is a round trip away
Section titled “A gotcha: expandWindowFlyout’s completion is a round trip away”If your own markup needs a compensating CSS shift while the window is expanded (e.g. to keep something visually anchored while the window grows upward), only apply it once expandWindowFlyout()’s promise has actually resolved — never speculatively beforehand. The promise doesn’t resolve until the native move/resize has genuinely landed, so touching compensating CSS any earlier means it’s now describing a window that doesn’t exist yet, which reads as your content jumping to the wrong spot until the real window catches up a beat later. Same in reverse for collapseWindowFlyout() — reset any compensating CSS only after it resolves, not before.
A gotcha: interactive controls inside a drag region
Section titled “A gotcha: interactive controls inside a drag region”If you wire a container’s mousedown to amee.startWindowDrag() and then nest an interactive control — a seek bar, a button — inside that same container, its clicks will also trigger a window drag unless you stop that. Call e.stopPropagation() in the control’s own mousedown listener before it bubbles up:
progressBar.addEventListener("mousedown", (e) => e.stopPropagation());progressBar.addEventListener("click", (e) => { /* seek */ });See the bundled classic skin’s progress bar for a complete example.