> For the complete documentation index, see [llms.txt](https://docs.amee.thiennguyen.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.amee.thiennguyen.dev/customization/skins.md).

# Skins

A skin is how the mini player's UI itself gets replaced — fully rebuilt: your own layout, your own controls, your own visualizer, anything with real logic. `mount(container, amee)` is handed an empty `<div>` and the full `window.amee` SDK — album art, transport controls, seek, volume/mute, timing, the audio visualizer's spectrum data — and can build whatever UI it wants on top.

This replaces what used to be a separate "plugin" system that could only add a small bolt-on widget after a fixed built-in layout. A skin owns the *whole* mini player instead — including Amee's own default UI, which ships as an ordinary skin (`classic`, see [Worked example](#worked-example)) rather than special-cased app code.

## Security model — read this first

**Skins run with the exact same privileges as Amee itself.** There is no sandbox: a skin's JavaScript executes in the same window, with the same access to every one of Amee's own internal capabilities (including skin management), the same DOM, and the same network access as the rest of the app. A skin could, in principle, read your now-playing history, make arbitrary network requests, or misuse whatever OS permissions Amee has been granted (it already requests system-audio access for the visualizer).

This is a deliberate, explicit trade-off in favor of maximum flexibility for skin authors, made with that risk understood — not an oversight. Installing a skin only validates the package's *shape* (does `manifest.json` parse, does the declared entry file exist, are the declared dimensions sane); it makes no attempt to verify a skin's JavaScript is safe, because that's not something a file-format check can do.

**Only install a skin from a developer you trust**, the same way you'd think about installing a browser extension.

## What a skin is

A directory containing:

1. **`manifest.json`** at the root — the manifest:

   ```json
   {
     "id": "my-skin",
     "name": "My Skin",
     "author": "your-name",
     "description": "One line describing it.",
     "version": "1.0.0",
     "entry": "main.js",
     "width": 420,
     "height": 84,
     "resizable": true,
     "min_width": 360,
     "max_width": 900
   }
   ```

   `id` and `name` are required. `entry` defaults to `main.js` and must be a plain filename — no `/` or `..` — sitting at the package root (assets like CSS/images/fonts can live in subfolders; the JS entry point itself can't). `width`/`height` are the mini player window's desired size, in logical pixels (80–2000 each) — the window is resized to match whenever this skin becomes active.

   `content_height` (optional, defaults to `height` — no reserved space) lets you declare a *taller* window than your actual content needs, split evenly into blank space above and below it. Center your content vertically (`display: flex; flex-direction: column; justify-content: center` works well) and that reserved space is yours to pop something into — an expanding control, a slider, anything — using CSS alone, with **no runtime window move/resize call needed at all**. See `classic`'s volume flyout for a worked example. This sidesteps the timing gotcha below entirely, at the cost of the window always taking up that reserved screen real estate (invisible, but still there — clicks in it are passed through to whatever's behind the window automatically, so it doesn't block anything underneath).

   `resizable` (optional, default `false`) opts the mini-player window into being user drag-resizable while this skin is active — see [Resizable windows](#resizable-windows) below.

   `graceful_shutdown` (optional, default `false`) opts into a chance to run cleanup or a fade-out animation before Amee actually quits — see [Graceful shutdown](#graceful-shutdown) below.
2. **The entry file** (`main.js` by default) — an ES module exporting `mount(container, amee)`: `container` is a plain, empty `<div>` filling the mini player window; `amee` is the [SDK](/customization/sdk-reference.md). `mount` may return a cleanup function, called when the skin is switched away from.
3. Optionally, any other files your skin needs (CSS, images, fonts) — read them at runtime via `amee.getSkinAsset(path)`.

No build step, no bundler, no framework required — plain DOM APIs work fine, and that's what the bundled `classic` skin uses.

## Resizable windows

By default the mini player is a fixed-size window — a skin declares `width`/`height` and that's what it always is. Setting `resizable: true` in `manifest.json` lets the user drag-resize it instead, within bounds you control:

```json
{
  "resizable": true,
  "min_width": 360,
  "max_width": 900,
  "min_height": 520,
  "max_height": 520
}
```

`min_width`/`min_height`/`max_width`/`max_height` are all optional (each falls back to the global 80–2000 logical-pixel bounds) and are only accepted when `resizable` is `true`. Pin `min_height`/`max_height` equal to `height` (as `classic` does) to allow horizontal-only resizing — this is required if your manifest also sets `content_height`, since that reserved-space math is derived from `height` as a fixed value and would go stale against a live-resized one.

**The user's last drag-resized size for this skin is remembered** across app restarts and across switching to another skin and back — you don't need to do anything for this; it's handled entirely by the core app, the same way window position already is. If your manifest's own bounds later change (a skin update lowers `max_width`, say), a previously-saved size is clamped back into range rather than discarded.

**Reflow your own layout with `amee.onResize(callback)`** — fires immediately with the current content-area size, then again on every resize, in the same CSS-px units you already style in. A skin that ignores it still works (the window just resizes around an unchanged-size UI, gaining/losing dead space), but adapting is usually a couple of CSS rules plus recomputing anything that depends on measured widths.

## Graceful shutdown

By default, quitting Amee closes the mini player instantly — no chance for your skin to react. Setting `graceful_shutdown: true` in `manifest.json` opts in to a signal-then-wait sequence instead:

```json
{
  "graceful_shutdown": true,
  "graceful_shutdown_timeout_ms": 400
}
```

Register a callback with `amee.onShutdown(cb)` (typically in `mount()`, alongside your other subscriptions):

```js
amee.onShutdown(async () => {
  root.classList.add("fading-out"); // CSS opacity transition
  await new Promise((resolve) => setTimeout(resolve, 300));
});
```

When the user quits, Amee runs every registered `onShutdown` callback and waits for them all to settle before actually exiting — but only for up to `graceful_shutdown_timeout_ms` (optional, defaults to 1500ms if omitted, clamped to 100–10000ms). **This is best-effort, not a guarantee**: Amee force-quits once that timeout elapses regardless of whether your callback has finished, so a hung or slow callback can never block quitting. Treat it as "a little room for a fade-out or a final save," not a place to do anything that must complete.

A skin that doesn't set `graceful_shutdown` (every skin written before this feature existed) is completely unaffected — quit stays instant, exactly as before.

## Packaging as `.ybskin`

Distribute your skin directory as a `.ybskin` file — a zip archive with `manifest.json` at its root, renamed with a `.ybskin` extension. Nothing more exotic than that:

```sh
cd my-skin/
zip -r ../my-skin.ybskin .
```

On import, Amee extracts it to its own app-data directory and keeps a copy of the original archive so it can hand back an identical file later via **Export**.

## Extra windows and per-skin storage

Your `mount(container, amee)` entry owns the mini player, but nothing stops your skin from having other windows too — a settings form, an about panel, an equalizer, a lyrics view, whatever you want. There's no fixed set of window "kinds" and no separate API per use case: `amee.openSkinWindow(entry, options)` opens **any** JS file in your own package as its own normal, decorated, resizable window, as long as that file exports `mount(container, amee)` — exactly the same contract as your main entry.

```js
// main.js
document.querySelector(".gear-icon").addEventListener("click", () => {
  amee.openSkinWindow("settings.js", { title: "My Skin Settings", width: 320, height: 240 });
});
```

```js
// settings.js — a second entry file in the same package, next to manifest.json
export function mount(container, amee) {
  const input = document.createElement("input");
  input.type = "color";
  amee.storage.get("accentColor").then((saved) => {
    input.value = saved ?? "#8b7cff";
  });
  input.addEventListener("input", () => amee.storage.set("accentColor", input.value));
  container.append(input);
}
```

Calling `openSkinWindow("settings.js", ...)` again while that window is still open focuses it instead of opening a second one — safe to wire straight to a button with no open/already-open bookkeeping of your own.

`amee.storage` is how state gets shared back to your main mount (or any other window you've opened): one JSON value per key, scoped to your skin, persisted by Amee — the schema inside is entirely up to you.

`SkinWindowOptions` is `{ title?, width?, height?, minWidth?, minHeight?, resizable? }` — all optional; unset dimensions fall back to a reasonable default.

## Worked example

Amee's own default mini player, `classic`, is a real skin — not special-cased app code. It's a complete example covering everything in this document: artwork + metadata, a click-to-seek progress bar, transport controls, and volume/mute, in about 200 lines of plain DOM manipulation. Its "…" menu's "About Classic" row also doubles as a worked example of [Extra windows](#extra-windows-and-per-skin-storage): it reads its own manifest live via `getSkinAsset` to show name/version/description/author without hardcoding any of it.

## Installing a skin

Dashboard → **Skin** → **Install skin…**, pick your `.ybskin` file. It's extracted, validated, and installed immediately — activate it from the same panel to see it live in the mini player, no restart needed. A handful of skins (including `classic`) ship built-in and can't be deleted; anything you install yourself can be removed again from the same panel. **Reveal in Finder** opens the directory where installed skins are stored.

## Design notes

* **Why replace the old plugin system?** The original design only let a "plugin" add a small bolt-on widget after a fixed built-in layout. That didn't match what developers actually wanted: the ability to build the mini player's *entire* interface — their own layout, their own controls — packaged and installable. Skins are the full-power successor to that old plugin system.
* **Why full trust instead of a sandbox?** A sandboxed model (an isolated iframe talking to the host only through a narrow, curated message-passing API) is the safer default for community-contributed code, and was considered — it's what this project would recommend if starting from a blank slate with untrusted contributors in mind. Amee deliberately chose full trust instead, for maximum flexibility, with the risk understood. If that trade-off ever needs revisiting, the sandboxed model is the natural next step, and wouldn't require skin authors to change anything about the `mount(container, amee)` contract — only how `container` and `amee` are actually wired up under the hood.
* **Why a zip instead of a single file?** A skin that owns the whole UI often needs more than one file — custom fonts, images, a separate stylesheet — which a single-file plugin format couldn't accommodate. A zip is the simplest container that supports that without inventing a new packaging format.
* **Why can a skin resize the window but not the entry filename's location?** Window size is a genuine per-skin design choice (a skin built around large album art needs more room than a slim pill). Restricting the entry file to the package root is just to keep path resolution trivial and safe — nested assets don't have that restriction.

## Next: the SDK reference

See [the `window.amee` SDK reference](/customization/sdk-reference.md) for every method, event, and gotcha available to skin code.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.amee.thiennguyen.dev/customization/skins.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
