Author SHA1 Message Date
cdnutterandClaude Sonnet 5.5 99c4de91c4 iOS 1.0.12: more space between seek bar and transport buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:27:44 -07:00
cdnutterandClaude Sonnet 5.5 1a5dba78f7 Document /plex/ proxy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:12:25 -07:00
cdnutterandClaude Sonnet 5.5 7ca9d0374d Proxy Plex through stemserve (/plex/) so HTTPS pages avoid mixed-content blocks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:11:18 -07:00
cdnutterandClaude Sonnet 5.5 4b53a720c4 Web UI: fix Plex status text, PLEX_URL/PLEX_TOKEN env, local stem server auto-detect, WEB=0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:00:34 -07:00
rootandClaude Sonnet 5.5 2a4e848788 stemify: build-ipa writes an unsigned release IPA plus a personal signed IPA in prov/
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:41:36 -07:00
cdnutterandClaude Sonnet 5.5 629901616b ios: optional signing step in build-ipa.sh (profile + p12 from ios/prov)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:36:05 -07:00
cdnutterandClaude Opus 5.5 4fe7f6d16a ios: source.json for 1.0.11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:24:42 -07:00
cdnutterandClaude Opus 5.5 555a79657c ios 1.0.11: Now Playing tap opens the app
UIApplicationSupportsMultipleScenes off; the app is iPhone-only and CarPlay doesn't need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:24:40 -07:00
cdnutterandClaude Opus 5.5 9299fee9a0 Web player served by stemserve
Plex browsing, stem mixer with presets, shuffle, repeat all/one, playback
speed, queue, waveform seek. stemserve serves web/ at / without the token.
stemserve image now builds from the repo root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:30:02 -07:00
cdnutterandClaude Opus 5.5 605b88f1eb ios: source.json for 1.0.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:21:34 -07:00
cdnutterandClaude Opus 5.5 b0dba777f9 ios 1.0.10: shuffle/repeat on Now Playing, playback speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:21:32 -07:00
cdnutterandClaude Opus 5.5 5b767cacc2 README: iOS has the EQ too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:50:22 -07:00
cdnutterandClaude Opus 5.5 bba92967ea README: iOS section, stem playlists, tighter layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:50:14 -07:00
cdnutterandClaude Opus 5.5 068f2309b4 iOS: source.json for 1.0.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:27 -07:00
cdnutterandClaude Opus 5.5 eb541b9a16 iOS 1.0.9: Add to Playlist sheet
Replaces the chain of dialogs (each waited ~450 ms for the last to close)
with one sheet: Plex | Stem tabs, inline new-playlist name field, playlist
rows with art, and mix chips for stem playlists. One "Add to Playlist…"
menu entry now covers both kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:25 -07:00
cdnutterandClaude Opus 5.5 790f8a35a2 iOS: source.json for 1.0.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:02:44 -07:00
cdnutterandClaude Opus 5.5 e2cf62cb07 iOS 1.0.8: stem playlists, karaoke softness slider in lyrics
- Playlists tab splits into Plex and Stem. Plex playlists are unchanged and
  now show their custom poster when one is set.
- Stem playlists live on the device; each song keeps its own mix. Add from
  any song or collection menu, or copy a whole Plex playlist.
- Queue items can carry a mix, which wins over the song's saved mix.
- Softness slider under the Vocals picker in lyrics when set to Soft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:02:42 -07:00
cdnutterandClaude Opus 5.5 6f634522e5 ios: source.json for 1.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:39:04 -07:00
cdnutterandClaude Opus 5.5 d52a3e257f ios: Liquid Glass mini player, adjustable karaoke softness (1.0.7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:39:02 -07:00
cdnutterandClaude Opus 5.5 e98d595365 ios: source.json for 1.0.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:31:01 -07:00
cdnutterandClaude Opus 5.5 41b8171df2 ios: karaoke vocals On/Soft/Off (1.0.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:31:00 -07:00
cdnutterandClaude Opus 5.5 8e5ebb70b1 ios: source.json for 1.0.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:23:39 -07:00
cdnutterandClaude Opus 5.5 65bc69a7fe ios: sort artists, albums, songs and playlists (1.0.5)
Plexamp-style sort menu on each library list: pick a field, pick it again
to flip direction. Choice is saved per screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:23:38 -07:00
cdnutterandClaude Opus 5.5 d149af336a iOS: keep older build numbers when regenerating the source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:42 -07:00
cdnutterandClaude Opus 5.5 3f7fd76762 iOS: source for 1.0.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:33 -07:00
cdnutterandClaude Opus 5.5 bea3f8f5a2 iOS 1.0.4: per-song saved mixes, karaoke mode with synced lyrics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:31 -07:00
cdnutterandClaude Opus 5.5 e0184f540b iOS: add AltStore source for 1.0.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:08:22 -07:00
cdnutterandClaude Opus 5.5 1123f460b0 iOS 1.0.3: bundle ID org.cdnutter.stemify, stemify-<version>.ipa, AltStore source generator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:08:11 -07:00
31 changed files with 2823 additions and 547 deletions

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ ios/build/
ios/Stemify.xcodeproj/ ios/Stemify.xcodeproj/
ios/Stemify/Info.plist ios/Stemify/Info.plist
ios/Stemify/Stemify.entitlements ios/Stemify/Stemify.entitlements
ios/prov
+210 -405
View File
@@ -2,117 +2,74 @@
# Stemify # Stemify
**Your whole music library, taken apart and put back together however you like.** Stemify splits every song in your Plex music library into stems (vocals, drums, bass, guitar, piano and other) and gives you players that mix them live. Mute the vocals and sing along, solo the bass to learn a part, or save an instrumental of a song and have it play that way every time.
Ever wanted to hear "Carry On Wayward Son" without the vocals so you can belt it out yourself? Or It runs on iPhone (with CarPlay), Android (with Android Auto) and Linux desktop. A GPU on your server does the separating in the background.
mute the drums on a song to figure out what the bass player is actually doing back there? Stemify
splits every song in your library into its separate parts (vocals, drums, bass, guitar, piano and
"other"), keeps them organised next to your music, and gives you players that let you mix them
live, on your phone, in your car, and on your desktop.
It's a home-grown, slightly over-engineered love letter to karaoke, music nerdery and the joy of - [How it works](#how-it-works)
pressing the "mute vocals" button at exactly the wrong moment. - [Apps](#apps)
- [Setup](#setup)
--- - [iOS](#ios)
- [Android](#android)
## Contents - [Desktop](#desktop)
- [Stem Now](#stem-now)
- [The big picture](#the-big-picture) - [Reference](#reference)
- [What you get](#what-you-get)
- [Setting it up](#setting-it-up)
- [1. The server: stemd and stemserve](#1-the-server-stemd-and-stemserve)
- [2. The Android app](#2-the-android-app)
- [3. The desktop apps (optional)](#3-the-desktop-apps-optional)
- [Using the Android app](#using-the-android-app)
- [Stem Now: skipping the line](#stem-now-skipping-the-line)
- [The Equalizer](#the-equalizer)
- [Android Auto](#android-auto)
- [iOS and CarPlay](#ios-and-carplay)
- [The desktop apps](#the-desktop-apps)
- [Separation models](#separation-models)
- [How the pieces find each other](#how-the-pieces-find-each-other)
- [stemserve API](#stemserve-api)
- [Building and testing](#building-and-testing) - [Building and testing](#building-and-testing)
- [Repo tour](#repo-tour) - [Troubleshooting](#troubleshooting)
- [Troubleshooting and FAQ](#troubleshooting-and-faq) - [Credits and license](#credits)
- [Credits](#credits)
- [License](#license)
--- ## How it works
## The big picture The stems folder mirrors the music folder. If a song lives at `Kansas/Leftoverture/01 Carry On.flac`, its stems live in `Stems/Kansas/Leftoverture/01 Carry On/`.
There are three moving parts, and they all agree on one simple idea: **the stems folder mirrors
the music folder.** If your song lives at `Kansas/Leftoverture/01 Carry On.flac`, its stems live at
`Stems/Kansas/Leftoverture/01 Carry On/`. That's it. That's the whole trick.
``` ```
┌──────────────────────────── your server (unraid, in our case) ───────────────────────────┐ your server
│ │ Music/ ──▶ stemd (GPU) ──▶ Stems/ ──▶ stemserve :8765 ──▶ phone, car, desktop
│ Music/ ──read──▶ stemd (GPU) ──writes──▶ Stems/ ──read──▶ stemserve (:8765) ─────┼──▶ phone, car, desktop ▲ │
│ ▲ ▲ │ │ └──── Stem Now requests ◀───┘
│ │ └──────── "Stem Now" requests ◀──────────────┘ │ Plex Media Server :32400 ───────────────────────────────▶ library, artwork, playlists
│ Plex Media Server (:32400) ──────────────────────────────────────────────────────────────┼──▶ library, artwork, playlists
└───────────────────────────────────────────────────────────────────────────────────────────┘
``` ```
- **stemd** sits on the server and chews through your library on the GPU, one song at a time, Three parts, and only stemd is required:
forever. New album? It notices within half an hour and gets to work.
- **stemserve** hands the finished stems to your devices over HTTP, with seeking, waveforms and
optional Opus transcodes so your phone storage doesn't cry.
- **Plex** stays in charge of what it's good at: your library, metadata, artwork and playlists.
- **The apps** (Android, Android Auto, and two desktop apps) play everything, and quietly fall back
to the original file for any song that hasn't been separated yet. Nothing is ever unplayable.
--- - **stemd** is the daemon. It does the actual separating and nothing else.
- **Stemify web UI** is optional, for desktop users. stemserve serves it at `/`. It plays the stems streamed from the server, and picks up a local stem server on `localhost:8765` by itself.
- **iOS and Android apps** are optional and built from this repo too.
## What you get - **stemd** works through the library on the GPU, one song at a time, and rescans every 30 minutes for new music.
- **stemserve** serves the finished stems over HTTP with range requests, waveforms and optional Opus transcodes.
- **Plex** handles the library, metadata, artwork and playlists.
- **The apps** play stems when a song has them and fall back to the original file when it doesn't, so everything plays.
**On your phone (Android)** ## Apps
- A Plex music player with per-stem faders, mute and solo, and one-tap mixes: Full, Karaoke,
A Cappella, No Drums, Rhythm, No Guitar, No Bass.
- A Plexamp-style waveform seek bar that doubles as a volume control (drag sideways to seek, up and
down for volume). The waveform is scaled to each song, so you can actually see the quiet verse
and the big chorus.
- Live level meters for every stem, updated every frame and in sync with what you hear.
- A 10-band graphic EQ **plus** Equalizer APO-style parametric filters, with per-device profiles
that switch automatically when you plug in headphones or connect to Bluetooth.
- Paste in an AutoEQ correction for your headphones and you're done.
- Downloads for offline listening, stems and all.
- Gapless playback, a proper queue, playlists (create, reorder, rename), search, and "On This
Day" album birthdays on the home screen.
- "Stem Now": jump any song, album, artist or playlist to the front of the separation queue.
- An iOS-flavoured look in Black, Dark or White, at whatever refresh rate your panel can manage.
**In your car (Android Auto)** | | iOS | Android | Desktop player | Web |
- A browse tree built for a rotary knob and no touchscreen, with voice search, a Vocals on/off |---|---|---|---|---|
button and a Mix button right next to play/pause. | Per-stem faders, mute, solo, mix presets | yes | yes | yes | yes |
| Waveform seek bar, live stem meters | yes | yes | | waveform |
| Shuffle, repeat all/one | yes | yes | | yes |
| Playback speed (keeps pitch) | yes | | | yes |
| Saved mix per song | yes | | | |
| Karaoke mode with synced lyrics | yes | | | |
| Stem playlists (a mix per song) | yes | | | |
| Plex playlists | yes | yes | yes | yes |
| Downloads for offline | yes | yes | | |
| 10-band and parametric EQ, AutoEQ import | yes | yes | | |
| Car | CarPlay | Android Auto | | |
| Voice | Siri | Google Assistant | | |
| Stem Now | yes | yes | | |
**On your desktop (Linux)** Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass.
- **Stemify (the separator)**: drag a song onto the window, get stems out the other side, preview them in sync,
export them. Material layout meets Windows 7 Aero glass, running at 240 Hz because why not.
- **Stemify Player**: your Plex library and its stems, with full mixing... dressed up as
Microsoft Excel 2016. Yes, really. Your boss will think you're working on the quarterly numbers.
**On the server** ## Setup
- **stemd** separates everything automatically with BS-RoFormer SW (6 stems) on an NVIDIA GPU,
politely, at low priority, sharing the card with Plex transcodes.
- **stemserve** is one Python file using only the standard library. ffmpeg is optional.
--- Set up the server first, then the apps. The examples use Unraid paths and `192.168.86.2` for the server; use your own.
## Setting it up ### Server
The recommended order is server first, then phone, then (if you like) desktop. The examples below You need Docker, and an NVIDIA GPU for stemd (on Unraid, install the Nvidia-Driver plugin). A CPU build exists for testing, but it's far too slow for a whole library.
use unraid paths and the addresses from the original setup (`192.168.86.2`). Swap in your own.
### 1. The server: stemd and stemserve Make the folders, owned by `99:100` (Unraid's nobody:users):
You need Docker, and for stemd an NVIDIA GPU (on unraid, install the **Nvidia-Driver** plugin from
the Apps tab). A CPU-only build exists for testing, but separating a whole library on a CPU is a
good way to celebrate several birthdays while you wait.
**Folders first** (owned by `99:100`, unraid's nobody:users):
```sh ```sh
mkdir -p /mnt/user/appdata/stemserve/cache \ mkdir -p /mnt/user/appdata/stemserve/cache \
@@ -121,8 +78,7 @@ mkdir -p /mnt/user/appdata/stemserve/cache \
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
``` ```
**stemd** builds from the repo root because it borrows the `stemify` package. Copy the repo (or **stemd** builds from the repo root because it uses the `stemify` package. Copy the repo (at least `stemify/`, `server/` and `.dockerignore`) to `/mnt/user/appdata/stemd/src`, then:
at least `stemify/`, `server/` and `.dockerignore`) to `/mnt/user/appdata/stemd/src`, then:
```sh ```sh
docker build -f /mnt/user/appdata/stemd/src/server/stemd.Dockerfile -t stemd /mnt/user/appdata/stemd/src docker build -f /mnt/user/appdata/stemd/src/server/stemd.Dockerfile -t stemd /mnt/user/appdata/stemd/src
@@ -133,24 +89,14 @@ docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \
-v /mnt/user/appdata/stemd/models:/models \ -v /mnt/user/appdata/stemd/models:/models \
-v /mnt/user/appdata/stemd/state:/state \ -v /mnt/user/appdata/stemd/state:/state \
stemd stemd
docker logs -f stemd
``` ```
The first song downloads the model (about 700 MB). After that the log reads like a very calm The first song downloads the model (about 700 MB). After that the log shows what's left, what it's on, and how fast it's going.
sports commentary: how many tracks are left, which one it's on, and how many times faster than
realtime it went.
**stemserve** is tiny. Copy the `server/` folder to `/mnt/user/appdata/stemserve` and either use **stemserve** is one Python file using only the standard library. It also serves the web player from `web/`. Copy the repo's `server/` and `web/` folders to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` in `server/`, or by hand:
the included compose file:
```sh ```sh
cd /mnt/user/appdata/stemserve && docker compose up -d --build docker build -f /mnt/user/appdata/stemserve/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve
```
or run it by hand:
```sh
docker build -t stemserve /mnt/user/appdata/stemserve
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \ docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
-v /mnt/user/Music/Stems:/stems:ro \ -v /mnt/user/Music/Stems:/stems:ro \
-v /mnt/user/appdata/stemserve/cache:/cache \ -v /mnt/user/appdata/stemserve/cache:/cache \
@@ -158,423 +104,282 @@ docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
stemserve stemserve
``` ```
Check it from any machine on your network: `curl http://192.168.86.2:8765/api/info` should return JSON with a track count.
```sh To update, copy the new files, rebuild, `docker rm -f` the container and run it again. Everything that matters lives in the mounted folders. Update stemd before stemserve.
curl http://192.168.86.2:8765/api/info
```
If you see a happy little JSON blob with a track count, you're in business.
<details> <details>
<summary><b>Server settings (environment variables)</b></summary> <summary>Server environment variables</summary>
**stemd** **stemd**
| Variable | Default | What it does | | Variable | Default | What it does |
|---|---|---| |---|---|---|
| `MUSIC_DIR` | `/music` | Your music library (read-only is fine) | | `MUSIC_DIR` | `/music` | Music library (read-only is fine) |
| `STEMS_DIR` | `/stems` | Where stems go | | `STEMS_DIR` | `/stems` | Where stems go |
| `STATE_DIR` | `/state` | The failed-track list lives here | | `STATE_DIR` | `/state` | Holds the failed-track list |
| `QUEUE_DIR` | `STATE_DIR/queue` | Where "Stem Now" requests arrive | | `QUEUE_DIR` | `STATE_DIR/queue` | Where Stem Now requests arrive |
| `MODELS_DIR` | `/models` | Model weights, downloaded on first use | | `MODELS_DIR` | `/models` | Model weights, downloaded on first use |
| `MODEL` | `roformer_bs_sw` | Any model id from [the table below](#separation-models) | | `MODEL` | `roformer_bs_sw` | Any id from [the model table](#separation-models) |
| `DEVICE` | `cuda` | `cuda`, `cuda:N` or `cpu` | | `DEVICE` | `cuda` | `cuda`, `cuda:N` or `cpu` |
| `RESCAN_MIN` | `30` | Minutes between library scans once it's caught up | | `RESCAN_MIN` | `30` | Minutes between library scans once caught up |
| `FP16` | `1` | Half precision on the GPU (`0` for full fp32) | | `FP16` | `1` | Half precision on the GPU (`0` for fp32) |
| `OVERLAP`, `SEGMENT`, `SHIFTS` | `0.25`, `0`, `0` | Separation quality knobs, same as the desktop app | | `OVERLAP`, `SEGMENT`, `SHIFTS` | `0.25`, `0`, `0` | Separation quality, same as the desktop app |
`docker build --build-arg TORCH_INDEX=cpu ...` makes a small GPU-less image for testing. `docker build --build-arg TORCH_INDEX=cpu ...` makes a small CPU-only image.
**stemserve** **stemserve**
| Variable | Default | What it does | | Variable | Default | What it does |
|---|---|---| |---|---|---|
| `STEMS_DIR` | `/stems` | The stems folder | | `STEMS_DIR` | `/stems` | The stems folder |
| `CACHE_DIR` | `/cache` | Opus transcodes and waveforms (safe to delete any time) | | `CACHE_DIR` | `/cache` | Opus transcodes and waveforms (safe to delete) |
| `QUEUE_DIR` | `/queue` | stemd's "Stem Now" folder, shared with it | | `QUEUE_DIR` | `/queue` | stemd's Stem Now folder, shared with it |
| `PORT` | `8765` | Listen port | | `PORT` | `8765` | Listen port |
| `TOKEN` | empty | If set, every request needs `X-Stem-Token` or `?token=` | | `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder | | `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `PLEX_URL` | empty | Plex address. With `PLEX_TOKEN`, the web UI connects to Plex without signing in |
| `PLEX_TOKEN` | empty | Plex token. Only handed out to clients that pass `TOKEN`, so set `TOKEN` too |
| `WEB` | `1` | Set to `0` to turn off the web UI and serve the API only |
</details> </details>
**Updating later:** copy the new files over, rebuild the image, `docker rm -f` the container, and ### Connecting an app
run it again. Your stems, models and settings live in the mounted folders, so nothing is lost.
Update stemd before stemserve: if you separated songs before the project was called Stemify,
stemd renames their old done-markers on its first start so nothing gets separated twice.
### 2. The Android app Every app asks for the same two things:
Built for (and lovingly tested on) a OnePlus 15, but any recent Android phone should be happy. 1. **Plex:** sign in with the 4-character code at [plex.tv/link](https://plex.tv/link) and pick your music library.
2. **Stem server:** in Settings, enter your stemserve address. It should say Online with a song count.
1. Grab `Stemify.apk` from the Releases page, or [build it yourself](#building-and-testing), The apps check stemserve for newly separated songs every minute, so the library fills in as stemd works.
then sideload it:
```sh
adb install -r Stemify.apk
```
2. Open it and go to **Library → Connect** (or Settings → Plex). Enter the 4-character code at
[plex.tv/link](https://plex.tv/link) and pick your music library.
3. Visit **Settings → Stem Server** and point it at your stemserve. It should say "Online" with a
song count. (The default address is `http://192.168.86.2:8765`, which is almost certainly not
yours.)
The app checks stemserve for freshly separated songs every minute, so your library slowly "lights ## iOS
up" as stemd works through it.
### 3. The desktop apps (optional) A SwiftUI app for iOS 17 or newer, with CarPlay and Siri ("play Paranoid in Stemify").
Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). The default setup uses PyTorch for ROCm ### Install
(AMD GPUs, developed on an RX 9070 XT). NVIDIA and CPU work too if you point the torch index
elsewhere in `pyproject.toml`.
```sh Grab `stemify-<version>.ipa` from [Releases](https://git.iamchrisama.com/cdnutter/stemify/releases) and sideload it with AltStore, SideStore, Sideloadly or Feather. To get updates in AltStore, SideStore or Feather, add this source:
uv sync
uv run stemify # the drag-and-drop separator ```
uv run stemify-player # the Excel-shaped music player https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json
./scripts/install-desktop.sh # adds both to your app grid ("--uninstall" removes them)
``` ```
--- CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free Apple ID can't sign. On a free sideload the app works on the phone but not in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team.
## Using the Android app ### Using it
**Tabs:** Library, Playlists, Search, Downloads and Settings. - **Now Playing:** shuffle and repeat sit either side of the play controls; tap repeat again for repeat one. The speed button (next to the mixer) goes from 0.5× to 2× without changing pitch.
- **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
- **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet.
- **Playlists:** the Playlists tab switches between Plex and Stem.
- Plex playlists sync with Plex and Plexamp.
- Stem playlists stay on the phone, and each song in them has its own mix, so one playlist can hold an instrumental of one song and an a cappella of the next.
- Copy any Plex playlist to a stem playlist from its menu.
- **Add to Playlist:** hold any song, album, artist or playlist. One sheet covers both kinds: pick Plex or Stem, pick the mix for stem playlists, then tap a playlist or make a new one right there.
- **Sorting:** artists, albums, songs and playlists sort like Plexamp. Songs has a shuffle-everything button.
- **Hold anything** for Add to Front or End of Queue, Download, Add to Playlist and Stem Now.
- **Equalizer:** same graphic EQ, parametric filters and APO/AutoEQ import as [Android](#equalizer).
**Stems are the norm, so the exceptions are what's marked.** Songs without stems yet say ## Android
**NO STEMS**, and albums and artists say **NO STEMS** or something like **7/10 STEMS** until
they're done. Those songs still play, just from the original file.
**Hold anything** (a song, album, artist or playlist) for: Kotlin and Jetpack Compose, built and tested on a OnePlus 15. Grab `Stemify.apk` from Releases (or [build it](#building-and-testing)) and `adb install -r Stemify.apk`.
- **Add to Front of Queue** / **Add to End of Queue** ### Using it
- **Download** (or **Remove Download**)
- **Add to Playlist** (playlists can even go into other playlists)
- **Stem Now**, while any of it lacks stems
**The Now Playing screen** has the waveform seek bar, the mixer (faders, mute, solo, mix presets, - **Tabs:** Library, Playlists, Search, Downloads and Settings.
meters, master volume) and the queue. Your mix can carry over from song to song or reset each - **Badges:** songs without stems yet say NO STEMS, and albums and artists show NO STEMS or a count like 7/10 STEMS. They still play from the original file.
time, your choice. - **Now Playing:** waveform seek bar (drag sideways to seek, up and down for volume), mixer, meters and queue. Your mix can carry over between songs or reset.
- **Stem Quality:** lossless FLAC, or Opus at a bitrate you choose (about an eighth of the size, transcoded and cached on the server).
- **Streaming** needs your home network, since Plex and stemserve are plain HTTP on the LAN. Downloads and already-streamed songs play anywhere.
**Downloads** are proper offline copies: every stem fully fetched (or the original file for songs ### Equalizer
without stems). Sort by download order, artist or release date.
**Stem Quality** decides how stems travel from the server: lossless FLAC, or Opus at a bitrate of Settings → Equalizer has two parts.
your choosing, which takes roughly an eighth of the space and is transcoded and cached on the
server.
**Streaming** needs your home network (Plex and stemserve are plain HTTP on the LAN). Anything - **Graphic EQ:** ten bands from 31 Hz to 16 kHz, with presets like Bass Booster, Late Night and Vocal Booster.
you've downloaded or already streamed plays anywhere. - **Parametric filters**, modeled on [Equalizer APO](https://sourceforge.net/projects/equalizerapo/): Peak, Low/High Shelf, Low/High Pass (6 to 48 dB/oct), Band Pass, Notch and All Pass. Each has frequency, gain and Q, works on both channels or one, and can be toggled on its own. Drag the handle on the curve or tap a value to type it.
--- To correct headphones, copy a `ParametricEQ.txt` from [AutoEQ](https://github.com/jaakkopasanen/AutoEq) and choose **••• → Paste APO Config** on the Filters page. **Copy as APO Config** goes the other way.
## Stem Now: skipping the line Profiles are tied to outputs (speaker, wired, each Bluetooth device, USB DACs) and switch automatically. Each has its own preamp, and anti-clipping adds just enough cut to keep the loudest point of the curve at 0 dBFS. A limiter on the master bus catches the rest.
stemd works through your library on its own, but a big library takes days. If you're desperate to ### Android Auto
karaoke your way through an album tonight, hold it and choose **Stem Now**.
Here's what happens: Built for a 2016 Mazda MX-5 with Mazda Connect: 800×480, no touch, knob and wheel buttons only.
1. The app sends the songs that still lack stems to stemserve. - Tabs for Playlists, Albums, Artists and Downloads (Downloads first when you're away from home).
2. stemserve drops a little request file into stemd's queue folder. - Every song list starts with Shuffle all, and lists over 40 items split into A to Z folders.
3. stemd finishes the song it's on, then does yours next, ahead of everything else. If it was - Now Playing has a Vocals on/off button and a Mix button that cycles presets. The mix shows on the artist line, like "Black Sabbath · Karaoke".
idle, it starts within about 5 seconds. - Voice: "play Paranoid on Stemify" works for playlists, artists, albums and songs.
4. About a minute after each song finishes, the app notices and plays it with stems from then on. - Away from home it notices the server is gone and only lists songs that will play.
Requests are handled oldest first and survive restarts. stemd matches the paths Plex reports to Android Auto hides sideloaded apps until you allow them: in the phone's Android Auto settings, tap Version ten times, then ⋮ → Developer settings → Unknown sources.
files in its own music folder by their artist/album/file tail, so it doesn't matter that Plex and
stemd see the library under different mount points. Songs that failed before (corrupt files, or
anything over 40 minutes) are skipped; delete `state/failed.json` to give them all another shot.
--- To test without a car, start the head unit server from Auto's developer settings, then:
## The Equalizer
Find it in **Settings → Equalizer**. It's two equalizers wearing one trench coat.
**The graphic EQ** has ten octave bands from 31 Hz to 16 kHz, a response curve, and a pile of
presets borrowed in spirit from iOS (Bass Booster, Late Night, Vocal Booster and friends).
**Parametric Filters** are modelled on [Equalizer APO](https://sourceforge.net/projects/equalizerapo/):
| Type | What it's for |
|---|---|
| Peak | Boost or cut around one frequency |
| Low Shelf / High Shelf | Tilt everything below or above a frequency |
| Low Pass / High Pass | Roll off the top or bottom, at 6, 12, 24, 36 or 48 dB per octave |
| Band Pass | Keep only a band |
| Notch | Surgically remove one frequency (hello, 60 Hz hum) |
| All Pass | Shift phase without changing level, for the true connoisseur |
Every filter has frequency, gain and Q, can apply to **both channels, or just left or right**,
and can be switched off on its own to compare. Drag a filter's handle on its curve (sideways for
frequency, up and down for gain), use the sliders, or tap any value to type it in.
**Headphone corrections in two taps:** copy a `ParametricEQ.txt` from
[AutoEQ](https://github.com/jaakkopasanen/AutoEq) for your headphones, then on the Filters page
choose **••• → Paste APO Config**. You can also **Copy as APO Config** to take your settings to a
PC.
**Profiles** link to output devices: the phone speaker, wired headphones, each Bluetooth headset,
USB DACs. Plug something in and its profile switches on automatically, or pick one by hand.
**Preamp and anti-clipping:** each profile has its own manual preamp, and the anti-clipping switch
adds just enough extra cut that a full-scale signal at the curve's loudest point still peaks at
0 dBFS. A limiter on the master bus catches anything else, so cranking the bass won't make things
crackle.
---
## Android Auto
Designed for a 2016 Mazda MX-5 with the Mazda Connect retrofit: an 800×480 screen with touch
disabled, driven entirely by the commander knob and the steering wheel buttons. If it works there,
it'll work in your car.
- **Four tabs:** Playlists, Albums, Artists, Downloads (Downloads comes first when you're away
from home).
- **Shallow and short:** every song list starts with **Shuffle all**, and anything longer than 40
items splits into A to Z folders, so you never have to twiddle the knob through 400 songs at a
red light.
- **Now playing:** next to the transport controls you get **Vocals** on/off (instant karaoke),
**Mix** (cycles through the presets), shuffle and repeat. The current mix shows on the artist
line, e.g. "Black Sabbath · Karaoke".
- **Voice:** hold the talk button and say "play Paranoid on Stemify". Playlists, artists,
albums and songs all work.
- **Offline:** away from home, the app quickly notices the server is unreachable and lists only
songs that will actually play (downloaded or fully cached).
**Setting up the car (once):** Android Auto hides sideloaded apps until you allow them. On the
phone, search Settings for "Android Auto", tap **Version** ten times, then in the ⋮ menu go to
**Developer settings** and tick **Unknown sources**. Stemify then shows up in Auto's app
launcher.
**Testing without a car:** Google's Desktop Head Unit can impersonate the Miata. On the phone,
start the head unit server from Auto's developer settings, then:
```sh ```sh
adb forward tcp:5277 tcp:5277 adb forward tcp:5277 tcp:5277
~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini ~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
``` ```
--- ## Web
## iOS and CarPlay stemserve also serves a web player at its root, so `http://<server>:8765/` opens it. Sign in with Plex in Settings (or paste a server URL and token), then browse albums, artists, playlists and search like the apps. **Stems only** lists everything in the stems folder and works without Plex.
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer. - Mixer, presets, shuffle, repeat, speed, queue and the waveform seek bar work like the phone apps. The mix carries over to the next song.
- Songs without stems play straight from Plex.
- Keys: Space play/pause, arrows seek, S shuffle, R repeat, `[` `]` speed, M mixer, Q queue.
- Settings, queue and mix are kept in the browser. Nothing syncs with the apps.
- If stemserve has `TOKEN` set, enter it in Settings.
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`): It's plain HTML and JavaScript in `web/` with no build step. To run it somewhere else, serve `web/` and proxy `/api/` and `/file/` to stemserve on the same origin; with a different origin the stem faders fall back to element volume, which iOS Safari ignores.
``` ## Desktop
cd ios
scripts/build-ipa.sh # writes build/Stemify.ipa Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). PyTorch defaults to ROCm (AMD); point the torch index in `pyproject.toml` elsewhere for NVIDIA or CPU.
```sh
uv sync
uv run stemify # the drag-and-drop separator
uv run stemify-player # the player
./scripts/install-desktop.sh # add both to your app launcher (--uninstall removes them)
``` ```
Sideload `build/Stemify.ipa` with Sideloadly or AltStore, which sign it with your Apple ID. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`. **Stemify** is the separator. Drop files on the window and each one is separated with your last-used model and device. You get a lane per stem with a waveform, solo, mute, gain and export (everything, one stem, or the current mix, as WAV, FLAC or MP3). Separation runs in its own process, results are cached, and it works on AMD, NVIDIA and CPU.
CarPlay and Siri need the `carplay-audio` and `siri` entitlements. A free Apple ID can't sign those, so on a free sideload the app works on the phone but won't show up in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team. **Stemify Player** is your Plex library and its stems, dressed up as Microsoft Excel 2016. Sheets are your library, the zoom slider is master volume, Cell Styles are mix presets, and Undo/Redo skip songs. It reports plays to Plex, supports media keys and MPRIS, and remembers your queue and mix. More in [`stemify/player/README.md`](stemify/player/README.md).
## The desktop apps ## Stem Now
### Stemify (the separator) A big library takes stemd days. Hold a song, album, artist or playlist and choose **Stem Now** to move its unseparated songs to the front.
Drop files onto the window. Each becomes a queue item, separation starts right away with your 1. The app sends the songs to stemserve, which drops a request file in stemd's queue folder.
last-used model and device, and when it's done you get a lane per stem with a waveform, solo, mute, 2. stemd finishes the song it's on and does yours next. If it was idle, it starts within about 5 seconds.
gain and export. Export everything, one stem, or your current mix, as WAV, FLAC or MP3. 3. About a minute after each song finishes, the app picks up its stems.
Other niceties: Requests run oldest first and survive restarts. stemd matches Plex's paths to its own files by the artist/album/file tail, so different mount points don't matter. Songs that failed before are skipped; delete `state/failed.json` to retry them.
- Separation runs in its own worker process, so the UI never stutters, even mid-separation. ## Reference
- Works on AMD (ROCm), NVIDIA and CPU, and shows free VRAM.
- Results are cached, so flipping back to an old song is instant (with a size limit you can set).
- A dev frame-time overlay, for anyone who wants proof of the 240 fps claims.
### Stemify Player (the spreadsheet) ### Separation models
The desktop sibling of the phone app, disguised as Microsoft Excel 2016. Some highlights from the | Model id | Stems | Notes |
translation guide: |---|---|---|
| `htdemucs` | 4 | HT Demucs, fast |
| `htdemucs_ft` | 4 | Fine-tuned, about 4x slower |
| `htdemucs_6s` | 6 | Adds guitar and piano |
| `roformer_bs_vocals` | 2 | BS-RoFormer vocals/instrumental (Viperx 1297) |
| `roformer_mel_kim_ft` | 2 | Mel-RoFormer, cleanest vocals (Kim FT by unwa) |
| `roformer_bs_sw` | 6 | BS-RoFormer SW by jarredou, stemd's default |
| In Excel | In Stemify Player | ### Folder layout
|---|---|
| Sheets (Songs, Artists, Albums, Playlists, Stems, Queue) | Your library |
| "Tell me what you want to do" | Search |
| Zoom slider | Master volume |
| Cell Styles | Mix presets (Karaoke is filed under "Bad", A Cappella under "Good") |
| AutoSum | AutoShuffle |
| Undo / Redo | Previous / next song |
| Format task pane | Now playing, with the stems as a column chart you can drag |
| `#GETTING_DATA` | A playlist loading from Plex |
It reports plays to Plex, supports media keys and desktop media controls (MPRIS), and remembers
your queue, position and mix between launches. The full tour is in
[`stemify/player/README.md`](stemify/player/README.md).
---
## Separation models
| Model id | Name | Stems | Notes |
|---|---|---|---|
| `htdemucs` | HT Demucs | 4 | Fast |
| `htdemucs_ft` | HT Demucs FT | 4 | Fine-tuned, about 4x slower |
| `htdemucs_6s` | HT Demucs 6-stem | 6 | Adds guitar and piano |
| `roformer_bs_vocals` | BS-RoFormer Vocals | 2 | Vocals / instrumental (Viperx 1297) |
| `roformer_mel_kim_ft` | Mel-RoFormer Vocals FT | 2 | Cleanest vocals (Kim FT by unwa) |
| `roformer_bs_sw` | BS-RoFormer SW 6-stem | 6 | stemd's default (by jarredou) |
The 4-stem models give vocals, drums, bass and other. The 6-stem ones add guitar and piano.
---
## How the pieces find each other
The stems folder mirrors the music folder, with one folder per song:
``` ```
Music/Kansas/Leftoverture/01 Carry On Wayward Son.flac Music/Kansas/Leftoverture/01 Carry On Wayward Son.flac
Stems/Kansas/Leftoverture/01 Carry On Wayward Son/ Stems/Kansas/Leftoverture/01 Carry On Wayward Son/
.stemify.json ← the "this one's done" marker .stemify.json done marker
01 Carry On Wayward Son - vocals.flac 01 Carry On Wayward Son - vocals.flac
01 Carry On Wayward Son - drums.flac 01 Carry On Wayward Son - drums.flac
01 Carry On Wayward Son - bass.flac
... ...
``` ```
- Stems are written as 24-bit FLAC into a hidden `.partial` folder first, and the marker goes in - Stems are written as 24-bit FLAC to a hidden `.partial` folder and the marker goes in last, so an interrupted separation is just redone.
last, so a separation interrupted by a reboot is simply redone next time. No half-baked songs. - If a folder has the same song in several formats, the best one wins (FLAC/WAV, then M4A, Opus/Ogg, MP3, WMA).
- When a folder holds the same song in several formats, the best one wins (FLAC/WAV, then M4A, - Plex reports paths as its container sees them, so the apps match songs to stems by path suffix, check durations, and learn the prefix from the first confident match.
Opus/Ogg, MP3, WMA).
- Plex reports file paths as *its* container sees them, so the apps match them to stems by suffix,
checking durations along the way, and learn the path prefix from the first confident match. A
song called "Intro.flac" on one album will never steal another album's intro.
--- ### stemserve API
## stemserve API
| Request | Returns | | Request | Returns |
|---|---| |---|---|
| `GET /api/info` | Track count, whether ffmpeg is available, whether Stem Now is set up | | `GET /api/info` | Track count, ffmpeg availability, whether Stem Now is set up |
| `GET /api/index` | Every finished song (with ETag, so unchanged indexes cost one tiny 304) | | `GET /api/index` | Every finished song (with ETag) |
| `GET /api/peaks?key=K&n=256` | Per-stem loudness envelopes for waveforms, 0 to 1000 | | `GET /api/peaks?key=K&n=256` | Per-stem loudness envelopes for waveforms, 0 to 1000 |
| `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background | | `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background |
| `POST /api/stem` with `{"paths": [...]}` | Stem Now: stemd separates these Plex files next | | `POST /api/stem` with `{"paths": [...]}` | Stem Now for these Plex files |
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem itself, with HTTP range support for seeking | | `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem, with range support |
| `GET /` | The web player from `web/` (or `WEB_DIR`), no token needed |
`K` is the song's folder under the stems root, e.g. `Kansas/Leftoverture/01 Carry On Wayward Son`. `K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker:
Want to run it outside Docker? It's one file:
```sh ```sh
STEMS_DIR=/path/to/Stems CACHE_DIR=~/.cache/stemserve python3 server/stemserve.py STEMS_DIR=/path/to/Stems CACHE_DIR=~/.cache/stemserve python3 server/stemserve.py
``` ```
--- ### Repo layout
```
ios/ iOS and CarPlay app (SwiftUI, xcodegen project)
Stemify/ App, Plex, Playback, Stems, Engine, EQ, CarPlay, UI
scripts/ build-ipa.sh, make-source.py
android/ Android and Android Auto app (Kotlin, Compose, Media3)
server/ stemd.py, stemserve.py, Dockerfiles, compose file
web/ web player (served by stemserve)
stemify/ desktop separator (PySide6 + QML) and shared separation code
worker/ Demucs and RoFormer backends, run in a separate process
player/ Stemify Player
library_core.py folder layout rules shared with stemd
scripts/ install-desktop.sh
specs.txt original design doc for the desktop separator
```
## Building and testing ## Building and testing
**iOS** needs a Mac with Xcode and xcodegen (`brew install xcodegen`):
```sh
cd ios
scripts/build-ipa.sh # unsigned build/stemify-<version>.ipa
xcodegen generate # or open Stemify.xcodeproj in Xcode
```
The version lives in `ios/project.yml` (`MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`). To release, tag `ios-v<version>`, attach `stemify-<version>.ipa`, then run `GITEA_TOKEN=... ios/scripts/make-source.py` and commit the new `ios/source.json`.
**Android** needs JDK 21 and the Android SDK (`android/local.properties` points at it): **Android** needs JDK 21 and the Android SDK (`android/local.properties` points at it):
```sh ```sh
cd android cd android
JAVA_HOME=/path/to/jdk-21 ./gradlew assembleRelease # app/build/outputs/apk/release/ JAVA_HOME=/path/to/jdk-21 ./gradlew assembleRelease # app/build/outputs/apk/release/
JAVA_HOME=/path/to/jdk-21 ./gradlew testDebugUnitTest # stem matching, EQ and filter maths, limiter, cache, car tree JAVA_HOME=/path/to/jdk-21 ./gradlew testDebugUnitTest # matching, EQ maths, limiter, cache, car tree
``` ```
The cache and index tests stream real stems from a stemserve on `localhost:8765` and skip The cache and index tests use a stemserve on `localhost:8765` and skip themselves without one.
themselves when there isn't one. Debug builds include a hook for screenshots and back-gesture
testing (`app/src/debug`); release builds don't.
**Desktop** apps can be checked without opening a window: **Desktop** can be checked without a window:
```sh ```sh
QT_QPA_PLATFORM=offscreen uv run stemify-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6 QT_QPA_PLATFORM=offscreen uv run stemify-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6
``` ```
--- ## Troubleshooting
## Repo tour **A song says NO STEMS.** Hold it and choose Stem Now.
``` **Stem Now says "stemserve needs updating" or "queue folder isn't mounted".** stemserve is older than the app or is missing the `/queue` mount. The queue folder has to be owned by `99:100` before the container first starts, or Docker creates it as root.
android/ the Android and Android Auto app (Kotlin, Jetpack Compose, Media3)
app/src/main/java/com/stemify/mobile/
plex/ Plex library, search, playlists, artwork, play reporting
stems/ stemserve client, path matching, which albums have stems
cache/ sparse block cache for streaming, and downloads
engine/ decoders, the sample-locked mixer, EQ, limiter, meters
eq/ EQ profiles, device switching, APO config import/export
playback/ queue, audio focus, mix presets, the Media3 session
auto/ Android Auto browse tree, voice search, offline mode
ui/ every screen, hand-built iOS-style components
server/
stemd.py the GPU separation daemon
stemserve.py the stem file server (standard library only)
*.Dockerfile, docker-compose.yml
stemify/ the desktop separator (PySide6 + QML) and shared separation code
worker/ separation backends (Demucs, RoFormer) in a separate process
player/ Stemify Player, the Excel-shaped one
library_core.py the folder layout rules shared with stemd
scripts/install-desktop.sh
specs.txt the original design doc for the desktop separator
```
--- **stemd is separating on the CPU.** The container can't see the GPU. Run it with `--runtime=nvidia` and the NVIDIA variables above.
## Troubleshooting and FAQ **stemd says "out of GPU memory".** Something else is using VRAM. stemd retries that song next pass without marking it failed.
**A song says NO STEMS and I'm impatient.** **The stem server shows Offline.** Check the address in Settings, try `curl http://<server>:8765/api/info` from another machine, and if stemserve has a `TOKEN`, set the same one in the app.
Hold it and choose **Stem Now**. It'll be next in line.
**Stem Now says "stemserve needs updating" or "queue folder isn't mounted".** **Stemify isn't in CarPlay or Siri.** The IPA was signed without those entitlements. See [iOS install](#install).
stemserve is older than the app, or it's missing the `/queue` mount. See
[the server setup](#1-the-server-stemd-and-stemserve). The queue folder needs to be owned by
`99:100` *before* the container first starts, or Docker creates it as root and nobody can write
to it.
**stemd says it's separating on the CPU.** **Stemify isn't in Android Auto.** Turn on Unknown sources in Auto's developer settings. See [Android Auto](#android-auto).
The container can't see the GPU. Make sure it's running with `--runtime=nvidia` and the NVIDIA
environment variables shown above.
**stemd keeps saying "out of GPU memory".** **Songs listed in `state/failed.json`.** Usually corrupt files or "songs" over 40 minutes (DJ mixes, radio rips). They retry when the file changes; delete the file to retry everything.
Something else (Plex transcoding, a game server, your ambitions) is hogging VRAM. stemd shrugs and
retries that song on the next pass. Nothing is marked failed.
**The Stem Server page says Offline.**
Check the address in Settings → Stem Server, check `curl http://<server>:8765/api/info` from
another machine, and if you set a `TOKEN` on the server, set the same one in the app.
**The app doesn't show up in Android Auto.**
Sideloaded apps need **Unknown sources** turned on in Auto's developer settings. See
[setting up the car](#android-auto).
**Some songs are marked failed in `state/failed.json`.**
Usually corrupt files, or "songs" over 40 minutes (those are almost always DJ mixes or radio rips).
Failed tracks retry automatically when the file changes; delete the file to retry everything.
**Why is the desktop player an Excel spreadsheet?**
We don't talk about it.
---
## Credits ## Credits
Standing on the shoulders of some very clever people:
- [Demucs](https://github.com/adefossez/demucs) by Alexandre Défossez - [Demucs](https://github.com/adefossez/demucs) by Alexandre Défossez
- BS-RoFormer and Mel-RoFormer models, including the SW 6-stem model by jarredou, Viperx's 1297, - BS-RoFormer and Mel-RoFormer models (SW 6-stem by jarredou, Viperx 1297, Kim FT by unwa) via [python-audio-separator](https://github.com/nomadkaraoke/python-audio-separator)
and Kim's vocal model fine-tuned by unwa, via - [PyTorch](https://pytorch.org/), [PySide6](https://www.qt.io/qt-for-python), [Jetpack Compose](https://developer.android.com/compose), [Media3](https://developer.android.com/media/media3)
[python-audio-separator](https://github.com/nomadkaraoke/python-audio-separator) - [Equalizer APO](https://sourceforge.net/projects/equalizerapo/) and [AutoEQ](https://github.com/jaakkopasanen/AutoEq), whose formats the EQ reads and writes
- [PyTorch](https://pytorch.org/), [PySide6 / Qt](https://www.qt.io/qt-for-python), - [LRCLIB](https://lrclib.net) for synced lyrics
[Jetpack Compose](https://developer.android.com/compose) and - Inter, Selawik and Carlito fonts (licenses included alongside them)
[Media3](https://developer.android.com/media/media3)
- [Equalizer APO](https://sourceforge.net/projects/equalizerapo/) and
[AutoEQ](https://github.com/jaakkopasanen/AutoEq), whose formats the EQ speaks
- The Inter, Selawik and Carlito fonts (licences included alongside them)
- [Plex](https://www.plex.tv/), for keeping the library tidy so we didn't have to
Stemify isn't affiliated with Plex, Google, Microsoft or Mazda. It just likes them. Stemify isn't affiliated with Plex, Apple, Google, Microsoft or Mazda.
---
## License ## License
[GPL-3.0](LICENSE). Take it apart and put it back together however you like, just like the songs. [GPL-3.0](LICENSE)
+2
View File
@@ -20,6 +20,7 @@ final class AppGraph {
let cache: MediaCache let cache: MediaCache
let eq: EqController let eq: EqController
let playback: PlaybackManager let playback: PlaybackManager
let stemPlaylists: StemPlaylists
let downloads: Downloads let downloads: Downloads
let coverage: StemCoverage let coverage: StemCoverage
let nowPlaying: NowPlaying let nowPlaying: NowPlaying
@@ -59,6 +60,7 @@ final class AppGraph {
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 } cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
eq = EqController(dir: support) eq = EqController(dir: support)
stemPlaylists = StemPlaylists(dir: support)
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support) playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support) downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support) coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
+7
View File
@@ -185,7 +185,14 @@ final class Settings: @unchecked Sendable {
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } } var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
/// Downloads page order: 0 download order, 1 artist, 2 release date. /// Downloads page order: 0 download order, 1 artist, 2 release date.
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } } var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
/// How far down Soft karaoke puts the vocals, in dB.
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } } var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var playbackSpeed: Double {
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }
set { write(\.playbackSpeed, "playbackSpeed", newValue, 1.0) }
}
var masterVolume: Float { var masterVolume: Float {
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 } get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
set { write(\.masterVolume, "masterVolume", newValue, 0.85) } set { write(\.masterVolume, "masterVolume", newValue, 0.85) }
+29 -5
View File
@@ -104,6 +104,7 @@ private final class OutputTap: @unchecked Sendable {
private var lastNanos: Int64 = 0 private var lastNanos: Int64 = 0
private var runningFlag = false private var runningFlag = false
private var latencyNanos: Int64 = 0 private var latencyNanos: Int64 = 0
private var speedFactor = 1.0
private let scratch: UnsafeMutablePointer<Float> private let scratch: UnsafeMutablePointer<Float>
private static let scratchFrames = 4096 private static let scratchFrames = 4096
@@ -121,6 +122,12 @@ private final class OutputTap: @unchecked Sendable {
func setLatency(_ seconds: Double) { lock.with { latencyNanos = Int64(seconds * 1e9) } } func setLatency(_ seconds: Double) { lock.with { latencyNanos = Int64(seconds * 1e9) } }
/// Playback speed; the time-pitch unit pulls frames this much faster than real time.
var speed: Double {
get { lock.with { speedFactor } }
set { lock.with { speedFactor = newValue } }
}
func flush() { func flush() {
lock.with { lock.with {
ring.clear() ring.clear()
@@ -135,7 +142,7 @@ private final class OutputTap: @unchecked Sendable {
lock.with { lock.with {
if lastNanos == 0 { return 0 } if lastNanos == 0 { return 0 }
let elapsed = runningFlag ? max(0, now - lastNanos) : 0 let elapsed = runningFlag ? max(0, now - lastNanos) : 0
let est = lastStart + elapsed * Int64(rate) / 1_000_000_000 - latencyNanos * Int64(rate) / 1_000_000_000 let est = lastStart + Int64(Double((elapsed - latencyNanos) * Int64(rate)) * speedFactor) / 1_000_000_000
return min(max(0, min(est, consumed)), written) return min(max(0, min(est, consumed)), written)
} }
} }
@@ -203,9 +210,10 @@ final class StemEngine: @unchecked Sendable {
var rate: Int var rate: Int
var nanos: Int64 var nanos: Int64
var running: Bool var running: Bool
var speed = 1.0
func positionMs(_ now: Int64 = monoNanos()) -> Int64 { func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
if rate <= 0 { return 0 } if rate <= 0 { return 0 }
let f = running ? frame + (now - nanos) * Int64(rate) / 1_000_000_000 : frame let f = running ? frame + Int64(Double((now - nanos) * Int64(rate)) * speed) / 1_000_000_000 : frame
return f * 1000 / Int64(rate) return f * 1000 / Int64(rate)
} }
} }
@@ -223,6 +231,8 @@ final class StemEngine: @unchecked Sendable {
private let av = AVAudioEngine() private let av = AVAudioEngine()
private var node: AVAudioSourceNode? private var node: AVAudioSourceNode?
private var tap: OutputTap? private var tap: OutputTap?
private let pitch: AVAudioUnitTimePitch = { let p = AVAudioUnitTimePitch(); p.bypass = true; return p }()
private var speed = 1.0
private var trackRate = 0 private var trackRate = 0
private var written: Int64 = 0 private var written: Int64 = 0
private var segments: [Segment] = [] private var segments: [Segment] = []
@@ -321,6 +331,17 @@ final class StemEngine: @unchecked Sendable {
} }
} }
/// Playback speed, 0.5 to 2. Pitch stays the same.
func setSpeed(_ v: Double) {
post { [self] in
speed = min(max(v, 0.5), 2)
pitch.rate = Float(speed)
pitch.bypass = speed == 1
tap?.speed = speed
publish()
}
}
func seekTo(_ ms: Int64) { func seekTo(_ ms: Int64) {
post { [self] in post { [self] in
guard let s = session else { return } guard let s = session else { return }
@@ -601,12 +622,15 @@ final class StemEngine: @unchecked Sendable {
#endif #endif
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000)) let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
t.setLatency(Self.outputLatency()) t.setLatency(Self.outputLatency())
t.speed = speed
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else { guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
throw DecodeError("Unsupported sample rate \(rate)") throw DecodeError("Unsupported sample rate \(rate)")
} }
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) } let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
av.attach(n) av.attach(n)
av.connect(n, to: av.mainMixerNode, format: fmt) if pitch.engine == nil { av.attach(pitch) }
av.connect(n, to: pitch, format: fmt)
av.connect(pitch, to: av.mainMixerNode, format: fmt)
av.prepare() av.prepare()
node = n node = n
tap = t tap = t
@@ -655,9 +679,9 @@ final class StemEngine: @unchecked Sendable {
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
if let seg = segments.first { if let seg = segments.first {
let running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil let running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil
snapshot = Snapshot(token: seg.token, frame: seg.frameStart + max(0, p - seg.writtenStart), rate: rate, nanos: nanos, running: running) snapshot = Snapshot(token: seg.token, frame: seg.frameStart + max(0, p - seg.writtenStart), rate: rate, nanos: nanos, running: running, speed: speed)
} else { } else {
snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false) snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false, speed: speed)
} }
} }
+86
View File
@@ -0,0 +1,86 @@
import Foundation
struct LyricLine: Hashable {
let ms: Int64
let text: String
}
struct Lyrics {
var lines: [LyricLine] = []
/// False for plain lyrics, which have no timings (every [ms] is 0).
var synced = false
var instrumental = false
}
/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track,
/// misses only until the app restarts.
actor LyricsSource {
static let shared = LyricsSource()
private struct Record: Codable {
var syncedLyrics: String?
var plainLyrics: String?
var instrumental: Bool?
var duration: Double?
}
private let dir: URL
private var misses: Set<String> = []
private init() {
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
/// Nil when LRCLIB has nothing for this song or couldn't be reached.
func lyrics(_ t: Track) async -> Lyrics? {
let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json")
if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) }
if misses.contains(t.ratingKey) { return nil }
let secs = Double(t.durationMs) / 1000
var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))])
.flatMap { try? JSONDecoder().decode(Record.self, from: $0) }
if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]),
let found = try? JSONDecoder().decode([Record].self, from: data) {
// Closest length first, so a live or extended version doesn't win.
let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 }
rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first
}
guard let rec else {
misses.insert(t.ratingKey)
return nil
}
if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) }
return Self.parse(rec)
}
private func get(_ path: String, _ query: [String: String]) async -> Data? {
var c = URLComponents(string: "https://lrclib.net/api/\(path)")!
c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
var req = URLRequest(url: c.url!, timeoutInterval: 10)
req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent")
guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return data
}
private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/#
/// LRC: each line starts with one or more [mm:ss.xx] stamps.
private static func parse(_ r: Record) -> Lyrics {
if let lrc = r.syncedLyrics, !lrc.isEmpty {
var lines: [LyricLine] = []
for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) {
let stamps = raw.matches(of: stamp)
guard let last = stamps.last else { continue }
let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces)
for m in stamps {
let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000)
lines.append(LyricLine(ms: ms, text: text))
}
}
return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true)
}
let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) }
return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true)
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ final class NowPlaying: @unchecked Sendable {
MPMediaItemPropertyAlbumTitle: t.album, MPMediaItemPropertyAlbumTitle: t.album,
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000, MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000, MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000,
MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? 1.0 : 0.0, MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? pm.speed : 0.0,
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0), MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
+92 -11
View File
@@ -19,6 +19,8 @@ enum Stems {
} }
} }
enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off }
/// One stem's fader. [level] is the slider position; 0.8 is unity gain. /// One stem's fader. [level] is the slider position; 0.8 is unity gain.
struct StemSetting: Codable, Hashable { struct StemSetting: Codable, Hashable {
static let unity: Float = 0.8 static let unity: Float = 0.8
@@ -44,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
struct QueueItem: Codable, Hashable, Identifiable { struct QueueItem: Codable, Hashable, Identifiable {
var uid: Int64 var uid: Int64
var track: Track var track: Track
/// Set when the song came from a stem playlist; it plays with this mix instead of its saved one.
var mix: [String: StemSetting]?
var id: Int64 { uid } var id: Int64 { uid }
} }
@@ -106,6 +110,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
@ObservationIgnored private let eq: EqController @ObservationIgnored private let eq: EqController
@ObservationIgnored private(set) var engine: StemEngine! @ObservationIgnored private(set) var engine: StemEngine!
@ObservationIgnored private let queueFile: URL @ObservationIgnored private let queueFile: URL
@ObservationIgnored private let songMixFile: URL
private(set) var queue: [QueueItem] = [] private(set) var queue: [QueueItem] = []
private(set) var index = -1 private(set) var index = -1
@@ -118,9 +123,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
private(set) var stemNames: [String] = [] private(set) var stemNames: [String] = []
private(set) var mix: [String: StemSetting] = [:] private(set) var mix: [String: StemSetting] = [:]
private(set) var master: Float private(set) var master: Float
private(set) var speed = 1.0
private(set) var waveform: Waveform? private(set) var waveform: Waveform?
private(set) var durationMs: Int64 = 0 private(set) var durationMs: Int64 = 0
private(set) var message: AppMessage? private(set) var message: AppMessage?
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
private(set) var songMixes: [String: [String: StemSetting]] = [:]
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
private(set) var karaoke = false
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:] @ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:] @ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
@@ -148,11 +159,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
self.cache = cache self.cache = cache
self.eq = eq self.eq = eq
queueFile = dir.appendingPathComponent("queue.json") queueFile = dir.appendingPathComponent("queue.json")
songMixFile = dir.appendingPathComponent("song-mixes.json")
songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:]
master = settings.masterVolume master = settings.masterVolume
engine = StemEngine(listener: self) engine = StemEngine(listener: self)
eq.onParams = { [weak self] p in self?.engine.eqParams = p } eq.onParams = { [weak self] p in self?.engine.eqParams = p }
engine.eqParams = eq.currentParams() engine.eqParams = eq.currentParams()
engine.masterGain = masterTaper(master) engine.masterGain = masterTaper(master)
speed = settings.playbackSpeed
engine.setSpeed(speed)
restore() restore()
observeSession() observeSession()
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -194,16 +209,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
// ---- queue -------------------------------------------------------------------------- // ---- queue --------------------------------------------------------------------------
private func items(_ tracks: [Track]) -> [QueueItem] { private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
tracks.map { t in tracks.enumerated().map { i, t in
defer { nextUid += 1 } defer { nextUid += 1 }
return QueueItem(uid: nextUid, track: t) return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil })
} }
} }
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) { /// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
if tracks.isEmpty { return } if tracks.isEmpty { return }
let items = items(tracks) let items = items(tracks, mixes)
if doShuffle { if doShuffle {
unshuffled = items unshuffled = items
let first = start > 0 && start < items.count ? items[start] : items.randomElement()! let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
@@ -218,12 +234,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
} }
} }
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) } func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) } func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) }
private func insert(_ at: Int, _ tracks: [Track]) { private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) {
if tracks.isEmpty { return } if tracks.isEmpty { return }
let items = items(tracks) let items = items(tracks, mixes)
var q = queue var q = queue
q.insert(contentsOf: items, at: min(max(at, 0), q.count)) q.insert(contentsOf: items, at: min(max(at, 0), q.count))
queue = q queue = q
@@ -316,6 +332,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } } func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
static let speeds: [Double] = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2]
func setSpeed(_ v: Double) {
speed = min(max(v, 0.5), 2)
engine.setSpeed(speed)
settings.playbackSpeed = speed
changed()
}
// ---- transport ---------------------------------------------------------------------- // ---- transport ----------------------------------------------------------------------
func play() { func play() {
@@ -405,6 +430,62 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
pushMix() pushMix()
} }
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
/// else cleared (or kept, per settings).
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
if karaoke { return karaokeMix }
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
}
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
private var karaokeMix: [String: StemSetting] {
switch karaokeVocals {
case .On: return [:]
case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))]
case .Off: return ["vocals": StemSetting(muted: true)]
}
}
var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off }
func setKaraokeVocals(_ v: KaraokeVocals) {
settings.karaokeVocals = v.rawValue
if karaoke { applyMix(karaokeMix) }
}
func setKaraokeSoftDb(_ db: Int) {
settings.karaokeSoftDb = db
if karaoke && karaokeVocals == .Soft { applyMix(karaokeMix) }
}
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
func setKaraoke(_ on: Bool) {
guard on != karaoke else { return }
karaoke = on
if on {
preKaraokeMix = mix
applyMix(karaokeMix)
} else {
if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
preKaraokeMix = nil
changed()
}
}
var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } }
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
func forgetSongMix() { setSongMix(nil) }
private func setSongMix(_ m: [String: StemSetting]?) {
guard let key = current?.track.ratingKey else { return }
songMixes[key] = m
let all = songMixes, file = songMixFile
DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
}
}
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) { private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
var s = mix[name] ?? StemSetting() var s = mix[name] ?? StemSetting()
f(&s) f(&s)
@@ -462,7 +543,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
if play && !activateSession() { return } if play && !activateSession() { return }
playWhenReady = play playWhenReady = play
buffering = true buffering = true
if !settings.keepStemMix { mix = [:] } mix = startMix(item)
changed() changed()
save() save()
loadTask = Task { @MainActor [weak self] in loadTask = Task { @MainActor [weak self] in
@@ -635,7 +716,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let item = current else { return } guard let item = current else { return }
durationMs = item.track.durationMs durationMs = item.track.durationMs
waveform = nil waveform = nil
if !settings.keepStemMix { mix = [:] } mix = startMix(item)
source = currentEntry != nil ? .Stems : .Original source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"]) stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix() pushMix()
+86
View File
@@ -0,0 +1,86 @@
import Foundation
import Observation
/// One song in a stem playlist, saved with the mix it plays with.
struct StemPlaylistItem: Codable, Hashable, Identifiable {
var uid = UUID().uuidString
var track: Track
var mix: [String: StemSetting]
var id: String { uid }
}
struct StemPlaylist: Codable, Hashable, Identifiable {
var id = UUID().uuidString
var title: String
var items: [StemPlaylistItem] = []
var created = Date()
var updated = Date()
var tracks: [Track] { items.map(\.track) }
var mixes: [[String: StemSetting]] { items.map(\.mix) }
var thumb: String? { items.lazy.compactMap(\.track.thumb).first }
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
}
/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…).
/// Plex playlists can't hold a mix, so these are separate.
@MainActor @Observable
final class StemPlaylists {
private(set) var lists: [StemPlaylist] = []
@ObservationIgnored private let file: URL
/// Every stem a song can have, for naming saved mixes without loading the song.
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
init(dir: URL) {
file = dir.appendingPathComponent("stem-playlists.json")
lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? []
}
func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } }
@discardableResult
func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist {
let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) })
lists.insert(pl, at: 0)
save()
return pl
}
func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) {
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
}
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } }
}
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
func delete(_ id: String) {
lists.removeAll { $0.id == id }
save()
}
/// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one.
static func label(_ mix: [String: StemSetting]) -> String {
if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label }
if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" }
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
}
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
change(&lists[i])
lists[i].updated = Date()
save()
}
private func save() {
let all = lists, file = file
DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
}
}
}
+7 -7
View File
@@ -113,8 +113,8 @@ final class PlexClient: @unchecked Sendable {
// ---- library ------------------------------------------------------------------ // ---- library ------------------------------------------------------------------
func artists() async throws -> [Artist] { func artists(sort: String = "titleSort") async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() } try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
} }
func albums(sort: String = "titleSort") async throws -> [Album] { func albums(sort: String = "titleSort") async throws -> [Album] {
@@ -126,9 +126,9 @@ final class PlexClient: @unchecked Sendable {
.metadata.map { $0.toAlbum() } .metadata.map { $0.toAlbum() }
} }
/// One page of every track, sorted by title. Returns the page and the library's track count. /// One page of every track. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) { func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size) let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size) return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
} }
@@ -190,8 +190,8 @@ final class PlexClient: @unchecked Sendable {
// ---- playlists ---------------------------------------------------------------- // ---- playlists ----------------------------------------------------------------
func playlists() async throws -> [Playlist] { func playlists(sort: String = "titleSort") async throws -> [Playlist] {
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() } try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
} }
func playlistTracks(_ key: String) async throws -> [Track] { func playlistTracks(_ key: String) async throws -> [Track] {
+1 -1
View File
@@ -405,7 +405,7 @@ extension PlexItem {
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) } func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
func toPlaylist() -> Playlist { func toPlaylist() -> Playlist {
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0, Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
durationMs: duration ?? 0, smart: smart ?? false) durationMs: duration ?? 0, smart: smart ?? false)
} }
} }
@@ -0,0 +1,202 @@
import SwiftUI
/// One sheet for adding songs to a playlist: Plex or stem, new or existing, and for stem playlists the mix.
/// Replaces the chain of dialogs, each of which had to wait for the last to go away.
struct AddToPlaylistSheet: View {
let req: Overlays.AddToPlaylist
let overlays: Overlays
@Environment(\.dismiss) private var dismiss
@Environment(\.palette) private var p
@AppStorage("addPlaylistKind") private var kind = PlaylistKind.Plex
@State private var plex: LoadState<[Playlist]> = .loading
@State private var naming = false
@State private var name = ""
@State private var mixIndex = 0
@State private var busy: String?
@FocusState private var nameFocused: Bool
private var g: AppGraph { .shared }
private struct MixOption { var label: String; var mix: [String: StemSetting] }
/// What's playing now and the song's saved mix (for a single song), then the presets.
private var mixes: [MixOption] {
let pm = g.playback
var o: [MixOption] = []
if req.tracks.count == 1, let t = req.tracks.first {
if pm.current?.track.ratingKey == t.ratingKey { o.append(MixOption(label: "Now: \(StemPlaylists.label(pm.mix))", mix: pm.mix)) }
if let m = pm.songMixes[t.ratingKey] { o.append(MixOption(label: "Saved: \(StemPlaylists.label(m))", mix: m)) }
}
for preset in MixPresets.all {
if let m = preset.build(StemPlaylists.allStems) { o.append(MixOption(label: StemPlaylists.label(m), mix: m)) }
}
return o
}
private var plexKeys: [String] { req.tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } }
var body: some View {
NavigationStack {
List {
Section {
songHeader
Picker("Kind", selection: $kind) {
Text("Plex").tag(PlaylistKind.Plex)
Text("Stem").tag(PlaylistKind.Stem)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
if kind == .Stem {
Section("Mix") { mixChips }
}
Section {
newRow
if kind == .Plex { plexRows } else { stemRows }
}
}
.navigationTitle("Add to Playlist")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
.animation(.default, value: kind)
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.task {
let plexClient = g.plex
let skip = req.skip
await LoadState.load($plex) { try await plexClient.playlists().filter { !$0.smart && $0.ratingKey != skip } }
}
.onChange(of: kind) { _, _ in naming = false; name = "" }
}
private var songHeader: some View {
let t = req.tracks.first
return HStack(spacing: 12) {
Artwork(thumb: t?.thumb, size: 48, corner: 6, placeholder: "music.note")
VStack(alignment: .leading, spacing: 2) {
Text(req.tracks.count == 1 ? t?.title ?? "" : songs(req.tracks.count)).foregroundStyle(p.label).lineLimit(1)
Text(req.tracks.count == 1 ? t?.artist ?? "" : req.title).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
}
}
.listRowBackground(Color.clear)
}
private var mixChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(mixes.enumerated()), id: \.offset) { i, o in
let on = i == mixIndex
Button { mixIndex = i } label: {
Text(o.label).font(.subheadline.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.foregroundStyle(on ? Color.white : p.label)
.background(on ? p.accent : p.fill, in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
@ViewBuilder private var newRow: some View {
if naming {
HStack {
TextField(kind == .Plex ? "Playlist name" : "Stem playlist name", text: $name)
.focused($nameFocused).submitLabel(.done).onSubmit(create)
Button("Create", action: create).bold()
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy != nil)
}
} else {
Button {
naming = true
nameFocused = true
} label: {
Label(kind == .Plex ? "New Playlist" : "New Stem Playlist", systemImage: "plus").foregroundStyle(p.accent)
}
}
}
@ViewBuilder private var plexRows: some View {
if plexKeys.isEmpty {
Text("These songs aren't in Plex. Use a stem playlist instead.").foregroundStyle(p.label2)
} else {
switch plex {
case .loading: HStack { Spacer(); ProgressView(); Spacer() }
case .err(let e): Text("Couldn't load playlists: \(e)").foregroundStyle(p.label2)
case .ok(let lists):
ForEach(lists) { pl in
row(pl.title, pl.thumb, songs(pl.trackCount), "music.note.list", busy: busy == pl.ratingKey) { addPlex(pl) }
}
}
}
}
@ViewBuilder private var stemRows: some View {
ForEach(g.stemPlaylists.lists) { pl in
row(pl.title, pl.thumb, songs(pl.items.count), "slider.horizontal.3", busy: false) {
g.stemPlaylists.add(pl.id, tracks: req.tracks, mix: mixes[mixIndex].mix)
done("Added to \(pl.title)")
}
}
}
private func row(_ title: String, _ thumb: String?, _ sub: String, _ placeholder: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
Button(action: tap) {
HStack(spacing: 12) {
Artwork(thumb: thumb, size: 44, corner: 5, placeholder: placeholder)
VStack(alignment: .leading, spacing: 1) {
Text(title).foregroundStyle(p.label).lineLimit(1)
Text(sub).font(.caption).foregroundStyle(p.label2)
}
Spacer()
if busy { ProgressView() }
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(self.busy != nil)
}
private func create() {
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
if n.isEmpty || busy != nil { return }
if kind == .Stem {
g.stemPlaylists.create(n, tracks: req.tracks, mix: mixes[mixIndex].mix)
done("Created “\(n)”")
return
}
busy = "new"
let plexClient = g.plex, keys = plexKeys
Task {
do {
_ = try await plexClient.createPlaylist(n, trackKeys: keys)
done("Created “\(n)”")
} catch {
busy = nil
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
}
}
}
private func addPlex(_ pl: Playlist) {
busy = pl.ratingKey
let plexClient = g.plex, keys = plexKeys
Task {
do {
try await plexClient.addToPlaylist(pl.ratingKey, trackKeys: keys)
done("Added to \(pl.title)")
} catch {
busy = nil
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
}
}
}
private func done(_ msg: String) {
dismiss()
overlays.hud(msg)
}
}
+87 -23
View File
@@ -52,10 +52,49 @@ struct MediaMenu {
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] { func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
let plex = g.plex let plex = g.plex
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra, let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) }
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
confirmDownload: pl.trackCount > 50) confirmDownload: pl.trackCount > 50)
} }
func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] {
var a = [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
]
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
if !missing.isEmpty {
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
}
if pl.items.isEmpty { a = [] }
let lists = g.stemPlaylists
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
})
return extra + a
}
/// A song in a stem playlist: queue it with its mix, change the mix, or take it out.
func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] {
let lists = g.stemPlaylists
return [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
SheetAction("Change Mix…", icon: "slider.horizontal.3") {
chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) }
},
SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) },
]
}
/// For songs already in hand (downloads with no Plex album behind them). /// For songs already in hand (downloads with no Plex album behind them).
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] { func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) } let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
@@ -89,7 +128,7 @@ struct MediaMenu {
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) } overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
}) })
} }
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } }) a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
if !stemmed { a.append(stemNow(load)) } if !stemmed { a.append(stemNow(load)) }
return a + extra return a + extra
} }
@@ -132,31 +171,56 @@ struct MediaMenu {
} }
} }
func addToPlaylist(_ tracks: [Track], skip: String? = nil) { /// Plex or stem playlist, picked in one sheet.
Task { func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") {
let lists: [Playlist] overlays.addToPlaylist(tracks, title: title, skip: skip)
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch { }
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) {
let pm = g.playback
var acts: [SheetAction] = []
if tracks.count == 1, let t = tracks.first {
if pm.current?.track.ratingKey == t.ratingKey {
let m = pm.mix
acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) })
}
if let m = pm.songMixes[t.ratingKey] {
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
}
}
for p in MixPresets.all {
guard let m = p.build(StemPlaylists.allStems) else { continue }
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
}
overlays.actions("Choose a Mix", acts)
}
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
let lists = g.stemPlaylists
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
if tracks.isEmpty {
onCreated(lists.create(name))
return return
} }
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } chooseMix(tracks) { m in
if keys.isEmpty { onCreated(lists.create(name, tracks: tracks, mix: m))
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle") overlays.hud("Created “\(name)”")
return
} }
let plex = g.plex }
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in }
SheetAction(pl.title) {
Task { /// Makes a stem playlist from a Plex one, every song with the same mix.
do { func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys) let plex = g.plex
overlays.hud("Added to \(pl.title)") let lists = g.stemPlaylists
} catch { overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle") withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in
} chooseMix(tracks) { m in
} onCreated(lists.create(name, tracks: tracks, mix: m))
overlays.hud("Copied \(songs(tracks.count))")
} }
}) }
} }
} }
+10
View File
@@ -22,11 +22,13 @@ final class Overlays {
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void } struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void } struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String } struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
struct AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? }
var actions: Actions? var actions: Actions?
var confirm: Confirm? var confirm: Confirm?
var prompt: Prompt? var prompt: Prompt?
var hud: Hud? var hud: Hud?
var addToPlaylist: AddToPlaylist?
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's. /// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
var level = 0 var level = 0
@@ -59,6 +61,10 @@ final class Overlays {
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) } present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
} }
func addToPlaylist(_ tracks: [Track], title: String, skip: String?) {
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
}
func hud(_ text: String, _ icon: String = "checkmark") { func hud(_ text: String, _ icon: String = "checkmark") {
hudTask?.cancel() hudTask?.cancel()
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) } withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
@@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier {
let overlays: Overlays let overlays: Overlays
let level: Int let level: Int
@State private var text = "" @State private var text = ""
@Environment(\.palette) private var p
private var mine: Bool { overlays.level == level } private var mine: Bool { overlays.level == level }
@@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier {
if let m = p.message { Text(m) } if let m = p.message { Text(m) }
} }
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" } .onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
.sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in
AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent)
}
} }
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> { private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
+1 -1
View File
@@ -18,7 +18,7 @@ enum Route: Hashable {
case Artists, Albums, Songs, Playlists case Artists, Albums, Songs, Playlists
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case PlexAccount, StemServer, Quality, Storage, Appearance case PlexAccount, StemServer, Quality, Storage, Appearance
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist) case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
} }
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
+36 -6
View File
@@ -14,15 +14,12 @@ struct RootView: View {
root(t).miniPlayerRoom() root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() } .navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
} }
// Floats over the screens; each screen leaves room for it so nothing ends up underneath. .modifier(FloatingMiniPlayer())
.overlay(alignment: .bottom) {
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
}
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
.tabItem { Label(t.rawValue, systemImage: t.icon) } .tabItem { Label(t.rawValue, systemImage: t.icon) }
.tag(t) .tag(t)
} }
} }
.modifier(GlassMiniPlayerHost())
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen() NowPlayingScreen()
.overlayHost(ui.overlays, level: 1) .overlayHost(ui.overlays, level: 1)
@@ -68,6 +65,39 @@ struct RootView: View {
case .ArtistPage(let a): ArtistScreen(artist: a) case .ArtistPage(let a): ArtistScreen(artist: a)
case .AlbumPage(let a): AlbumScreen(album: a) case .AlbumPage(let a): AlbumScreen(album: a)
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl) case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
}
}
}
/// iOS 26.1 and up put the mini player in the tab bar's glass accessory; older ones float a card over each tab.
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
private struct GlassMiniPlayerHost: ViewModifier {
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if #available(iOS 26.1, *) {
content
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
.tabBarMinimizeBehavior(.onScrollDown)
} else {
content
}
}
}
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
private struct FloatingMiniPlayer: ViewModifier {
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if glassAccessory {
content
} else {
content
.overlay(alignment: .bottom) {
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
}
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
} }
} }
} }
@@ -76,7 +106,7 @@ private struct MiniPlayerRoom: ViewModifier {
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) { content.safeAreaInset(edge: .bottom, spacing: 0) {
if g.playback.current != nil { Color.clear.frame(height: 66) } if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
} }
} }
} }
+142 -16
View File
@@ -111,9 +111,131 @@ private struct LibraryRow: View {
// ---- artists -------------------------------------------------------------------------------- // ---- artists --------------------------------------------------------------------------------
// ---- sorting ---------------------------------------------------------------------------------
/// One way to sort a list. `key` is Plex's sort key; `desc` is the direction it starts in.
struct SortOption {
let title: String
let key: String
var desc = false
var up = "Ascending"
var down = "Descending"
/// Plex reverses a compound key by its first field.
func param(_ reversed: Bool) -> String {
guard desc != reversed else { return key }
var fields = key.split(separator: ",").map(String.init)
fields[0] += ":desc"
return fields.joined(separator: ",")
}
}
private let AZ = (up: "A to Z", down: "Z to A")
private let NEW = (up: "Oldest First", down: "Newest First")
private let MOST = (up: "Fewest First", down: "Most First")
let ARTIST_SORTS = [
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let ALBUM_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,year", up: AZ.up, down: AZ.down),
SortOption(title: "Year", key: "year", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Release Date", key: "originallyAvailableAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let SONG_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Album", key: "album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
let PLAYLIST_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Updated", key: "updatedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
/// A screen's sort choice, kept across launches under `sort.<name>`.
struct SortState: DynamicProperty {
let options: [SortOption]
@AppStorage private var index: Int
@AppStorage private var reversed: Bool
init(_ name: String, _ options: [SortOption]) {
self.options = options
_index = AppStorage(wrappedValue: 0, "sort.\(name)")
_reversed = AppStorage(wrappedValue: false, "sort.\(name).rev")
}
var current: Int { options.indices.contains(index) ? index : 0 }
var option: SortOption { options[current] }
var param: String { option.param(reversed) }
/// The default order (first option, its own direction), where a letter index makes sense.
var isDefault: Bool { current == 0 && !reversed }
/// Picking the current field again flips it, like Plexamp.
func pick(_ i: Int) {
if i == current { reversed.toggle() } else { index = i; reversed = false }
}
func setDescending(_ d: Bool) { reversed = d != option.desc }
var descending: Bool { option.desc != reversed }
}
/// Toolbar menu: the sort fields, then the direction for the chosen one.
struct SortMenu: View {
let sort: SortState
var body: some View {
Menu {
Section("Sort By") {
ForEach(sort.options.indices, id: \.self) { i in
Button { sort.pick(i) } label: {
if i == sort.current {
Label(sort.options[i].title, systemImage: sort.descending ? "chevron.down" : "chevron.up")
} else {
Text(sort.options[i].title)
}
}
}
}
Section {
Button { sort.setDescending(false) } label: {
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
}
Button { sort.setDescending(true) } label: {
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
}
// ---- artists ---------------------------------------------------------------------------------
struct ArtistsScreen: View { struct ArtistsScreen: View {
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@Environment(\.palette) private var p @Environment(\.palette) private var p
private let sort = SortState("artists", ARTIST_SORTS)
@State private var artists: LoadState<[Artist]> = .loading @State private var artists: LoadState<[Artist]> = .loading
@State private var reload = 0 @State private var reload = 0
@@ -135,14 +257,15 @@ struct ArtistsScreen: View {
} }
.background(p.bg) .background(p.bg)
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
if let list = artists.value, list.count > 30 { if sort.isDefault, let list = artists.value, list.count > 30 {
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) } SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
} }
} }
} }
.navigationTitle("Artists") .navigationTitle("Artists")
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() } .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
} }
} }
} }
@@ -195,11 +318,9 @@ private struct SectionIndex: View {
// ---- albums --------------------------------------------------------------------------------- // ---- albums ---------------------------------------------------------------------------------
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
struct AlbumsScreen: View { struct AlbumsScreen: View {
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@State private var sort = 0 private let sort = SortState("albums", ALBUM_SORTS)
@State private var albums: LoadState<[Album]> = .loading @State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0 @State private var reload = 0
@@ -210,16 +331,10 @@ struct AlbumsScreen: View {
} }
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
Button("Sort") {
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
})
}
}
} }
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") { .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) } await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
} }
} }
} }
@@ -233,6 +348,9 @@ struct SongsScreen: View {
@State private var total = -1 @State private var total = -1
@State private var loading = false @State private var loading = false
@State private var error: String? @State private var error: String?
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
@State private var gen = 0
private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far. /// Shuffles the whole library, not just the pages loaded so far.
@@ -264,17 +382,25 @@ struct SongsScreen: View {
if loading { LoadingRow() } if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } } if let error { ErrorRow(message: error) { loadMore() } }
} }
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.task { if tracks.isEmpty { loadMore() } } .task { if tracks.isEmpty { loadMore() } }
.onChange(of: sort.param) {
gen += 1; tracks = []; total = -1; error = nil; loading = false
loadMore()
}
} }
private func loadMore() { private func loadMore() {
if loading || (total >= 0 && tracks.count >= total) { return } if loading || (total >= 0 && tracks.count >= total) { return }
loading = true loading = true
let mine = gen, order = sort.param
Task { Task {
do { do {
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200) let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
guard mine == gen else { return }
tracks += page; total = n; error = nil tracks += page; total = n; error = nil
} catch { } catch {
guard mine == gen else { return }
self.error = error.localizedDescription self.error = error.localizedDescription
} }
loading = false loading = false
+262 -47
View File
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
enum NpPanel { case Art, Mixer, Queue } enum NpPanel { case Art, Mixer, Queue, Lyrics }
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) { func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
switch d.kind { switch d.kind {
@@ -105,6 +105,7 @@ struct NowPlayingScreen: View {
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady) case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() } case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() } case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
} }
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
@@ -123,6 +124,7 @@ struct NowPlayingScreen: View {
.tint(c.label) .tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint) .animation(.easeInOut(duration: 0.3), value: tint)
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") { .task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return } guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
tint = await albumColor(thumb, dark: base.dark) tint = await albumColor(thumb, dark: base.dark)
@@ -151,6 +153,7 @@ struct NowPlayingScreen: View {
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track)) ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, panel == .Art ? 34 : 18) .padding(.top, panel == .Art ? 34 : 18)
HStack { HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer() Spacer()
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous) TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
Spacer() Spacer()
@@ -167,11 +170,15 @@ struct NowPlayingScreen: View {
Spacer() Spacer()
TransportButton(symbol: "forward.fill", size: 34, action: pm.next) TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
Spacer() Spacer()
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
} }
.frame(height: 84) .frame(height: 84)
.padding(.top, 14)
HStack { HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
SpeedMenu()
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
} }
.frame(height: 48) .frame(height: 48)
@@ -180,6 +187,16 @@ struct NowPlayingScreen: View {
} }
/// Opens a panel, or goes back to the art if it's already open. /// Opens a panel, or goes back to the art if it's already open.
/// Karaoke: vocals down and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
private func karaoke() {
let pm = g.playback
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return }
pm.setKaraoke(!pm.karaoke)
panel = pm.karaoke ? .Lyrics : .Art
}
}
private func show(_ to: NpPanel) { private func show(_ to: NpPanel) {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to } withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
} }
@@ -233,6 +250,51 @@ private struct TransportButton: View {
} }
} }
/// Shuffle and repeat, either side of the transport. A dot underneath when on.
private struct SideToggle: View {
let symbol: String
let on: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Image(systemName: symbol).font(.system(size: 19, weight: .semibold)).contentTransition(.symbolEffect(.replace))
Circle().frame(width: 4, height: 4).opacity(on ? 1 : 0)
}
.foregroundStyle(on ? p.label : p.label3)
.frame(width: 40, height: 56)
.contentShape(Rectangle())
}
.buttonStyle(PressFade())
.sensoryFeedback(.selection, trigger: symbol + "\(on)")
}
}
/// Playback speed picker; shows the current speed.
private struct SpeedMenu: View {
private var g: AppGraph { .shared }
@Environment(\.palette) private var p
var body: some View {
let pm = g.playback
Menu {
Picker("Speed", selection: Binding(get: { pm.speed }, set: { pm.setSpeed($0) })) {
ForEach(PlaybackManager.speeds, id: \.self) { Text(Self.label($0)).tag($0) }
}
} label: {
Text(Self.label(pm.speed)).font(.system(size: 14, weight: .semibold).monospacedDigit())
.foregroundStyle(pm.speed == 1 ? p.label2 : p.bg)
.frame(minWidth: 40, minHeight: 40)
.padding(.horizontal, 2)
.background(pm.speed == 1 ? .clear : p.label, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.menuOrder(.fixed)
}
static func label(_ v: Double) -> String {
(v == v.rounded() ? String(Int(v)) : String(format: "%g", v)) + "×"
}
}
private struct PanelToggle: View { private struct PanelToggle: View {
let symbol: String let symbol: String
let on: Bool let on: Bool
@@ -490,6 +552,7 @@ private final class Meters {
} }
private struct MixerPanel: View { private struct MixerPanel: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p @Environment(\.palette) private var p
@State private var meters = Meters() @State private var meters = Meters()
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
@@ -510,6 +573,20 @@ private struct MixerPanel: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
let current = MixPresets.normalized(pm.mix) let current = MixPresets.normalized(pm.mix)
let saved = pm.songMix.map(MixPresets.normalized)
let isSaved = saved == current
PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) {
if isSaved {
pm.forgetSongMix()
ui.overlays.hud("Removed this song's mix", "bookmark.slash")
} else {
pm.saveSongMix()
ui.overlays.hud("Mix saved for this song", "bookmark.fill")
}
}
if let saved, !isSaved {
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
}
ForEach(MixPresets.all) { preset in ForEach(MixPresets.all) { preset in
if let target = preset.build(names) { if let target = preset.build(names) {
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) } PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
@@ -549,12 +626,17 @@ private struct MixerPanel: View {
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint. /// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
private struct PresetChip: View { private struct PresetChip: View {
let text: String let text: String
var icon: String? = nil
let selected: Bool let selected: Bool
let action: () -> Void let action: () -> Void
@Environment(\.palette) private var p @Environment(\.palette) private var p
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label) HStack(spacing: 5) {
if let icon { Image(systemName: icon) }
Text(text)
}
.font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
.padding(.horizontal, 14).frame(height: 32) .padding(.horizontal, 14).frame(height: 32)
.background(selected ? p.label : p.fill, in: Capsule()) .background(selected ? p.label : p.fill, in: Capsule())
} }
@@ -761,56 +843,189 @@ struct MiniPlayer: View {
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some View {
if let t = g.playback.current?.track {
if #available(iOS 26.1, *) { GlassMiniPlayer(track: t) } else { card(t) }
}
}
/// Before iOS 26: a card floating over each tab.
@ViewBuilder private func card(_ t: Track) -> some View {
let pm = g.playback let pm = g.playback
if let t = pm.current?.track { HStack(spacing: 0) {
HStack(spacing: 0) { Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12) VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 1) { Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1)
Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1) Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 6)
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView() }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
}
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
.foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
Button(action: pm.next) {
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
} }
.padding(.leading, 8).padding(.trailing, 6) Spacer(minLength: 6)
.frame(height: 60) MiniTransport(color: p.label, next: true)
.background { }
// A solid card in the app's own colours instead of system glass. .padding(.leading, 8).padding(.trailing, 6)
let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) .frame(height: 60)
ZStack { .background {
shape.fill(p.cell) // A solid card in the app's own colours instead of system glass.
shape.fill(p.fill.opacity(p.dark ? 0.5 : 0)) let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
shape.strokeBorder(p.separator, lineWidth: 1 / scale) ZStack {
} shape.fill(p.cell)
.shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4) shape.fill(p.fill.opacity(p.dark ? 0.5 : 0))
shape.strokeBorder(p.separator, lineWidth: 1 / scale)
} }
.overlay(alignment: .bottom) { .shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4)
// Thin progress line along the bottom of the card. }
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in .overlay(alignment: .bottom) {
let d = pm.durationMs // Thin progress line along the bottom of the card.
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0 TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2) let d = pm.durationMs
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
}
.padding(.horizontal, 14)
}
.padding(.horizontal, 8)
.padding(.bottom, 6)
.contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() }
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
.actionsMenu(ui.menu.track(t))
}
}
/// iOS 26: lives in the tab bar's Liquid Glass accessory, like Music. Shrinks to title and play when the bar collapses.
@available(iOS 26.1, *)
private struct GlassMiniPlayer: View {
let track: Track
@Environment(Ui.self) private var ui
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
private var inline: Bool { placement == .inline }
var body: some View {
HStack(spacing: 10) {
Artwork(thumb: track.thumb, size: inline ? 28 : 34, corner: 6)
VStack(alignment: .leading, spacing: 0) {
Text(track.title).font(.subheadline.weight(.semibold)).lineLimit(1)
if !inline { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
}
Spacer(minLength: 4)
MiniTransport(color: .primary, next: !inline)
}
.padding(.leading, 10).padding(.trailing, 6)
.contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() }
.actionsMenu(ui.menu.track(track))
}
}
/// Play/pause (spinner while buffering) and next, for the mini player.
private struct MiniTransport: View {
let color: Color
let next: Bool
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView() }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
}
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
.foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
if next {
Button(action: pm.next) {
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
}
}
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
private struct LyricsPanel: View {
let track: Track
@Environment(\.palette) private var p
@State private var lyrics: Lyrics?
@State private var loading = true
@State private var at = -1
private var g: AppGraph { .shared }
var body: some View {
VStack(spacing: 0) {
// Soft leaves the singer in quietly as a guide.
HStack(spacing: 12) {
Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) {
ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) }
} }
.padding(.horizontal, 14) .pickerStyle(.segmented)
}
.padding(.horizontal, 28).padding(.top, 8)
if g.playback.karaokeVocals == .Soft {
let db = g.settings.karaokeSoftDb
HStack(spacing: 12) {
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
}
.padding(.horizontal, 28).padding(.top, 8)
}
content
}
.task(id: track.ratingKey) { await follow() }
}
@ViewBuilder private var content: some View {
Group {
if loading {
ProgressView()
} else if let l = lyrics, !l.lines.isEmpty {
lines(l)
} else {
Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found")
.font(.title3.weight(.semibold)).foregroundStyle(p.label2)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func follow() async {
loading = true
at = -1
lyrics = await LyricsSource.shared.lyrics(track)
loading = false
guard let l = lyrics, l.synced else { return }
while !Task.isCancelled {
// A little early, so the line lights up as it's sung rather than after.
let pos = g.playback.positionMs() + 250
let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1
if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } }
try? await Task.sleep(nanoseconds: 150_000_000)
}
}
private func lines(_ l: Lyrics) -> some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: l.synced ? 18 : 6) {
ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in
Text(line.text.isEmpty ? "\u{266A}" : line.text)
.font(l.synced ? .title2.weight(.bold) : .body)
.foregroundStyle(!l.synced || i == at ? p.label : p.label3)
.scaleEffect(i == at ? 1.03 : 1, anchor: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { if l.synced { g.playback.seekTo(line.ms) } }
.id(i)
}
}
.padding(.horizontal, 28)
.padding(.vertical, l.synced ? 120 : 16)
}
.scrollIndicators(.hidden)
.onChange(of: at) { _, i in
if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } }
} }
.padding(.horizontal, 8)
.padding(.bottom, 6)
.contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() }
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
.actionsMenu(ui.menu.track(t))
} }
} }
} }
+155 -9
View File
@@ -2,31 +2,53 @@ import SwiftUI
// ---- playlists ------------------------------------------------------------------------------ // ---- playlists ------------------------------------------------------------------------------
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
struct PlaylistsScreen: View { struct PlaylistsScreen: View {
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@State private var lists: LoadState<[Playlist]> = .loading @State private var lists: LoadState<[Playlist]> = .loading
@State private var reload = 0 @State private var reload = 0
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
private let sort = SortState("playlists", PLAYLIST_SORTS)
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some View {
Page(title: "Playlists") { Page(title: "Playlists") {
Loaded(state: lists, retry: { reload += 1 }) { list in Picker("Kind", selection: $kind) {
if list.isEmpty { Text("Plex").tag(PlaylistKind.Plex)
EmptyState(icon: "music.note.list", title: "No Playlists", Text("Stem").tag(PlaylistKind.Stem)
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.") }
.pickerStyle(.segmented)
.padding(.horizontal, MARGIN).padding(.bottom, 8)
if kind == .Plex {
Loaded(state: lists, retry: { reload += 1 }) { list in
if list.isEmpty {
EmptyState(icon: "music.note.list", title: "No Playlists",
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
}
ForEach(list) { pl in
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
}
} }
ForEach(list) { pl in } else {
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) let stems = g.stemPlaylists.lists
if stems.isEmpty {
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
}
ForEach(stems) { pl in
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
} }
} }
} }
.toolbar { .toolbar {
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { newPlaylist() } label: { Image(systemName: "plus") } Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
} }
} }
.task(id: "\(g.settings.musicSection)|\(reload)") { .task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($lists) { try await g.plex.playlists() } await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
} }
} }
@@ -43,6 +65,130 @@ struct PlaylistsScreen: View {
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") } if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
else { ui.overlays.actions("New Playlist", acts) } else { ui.overlays.actions("New Playlist", acts) }
} }
private func newStemPlaylist() {
let menu = ui.menu
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
ui.overlays.actions("New Stem Playlist", [
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
Task {
guard let plex = try? await g.plex.playlists() else {
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
return
}
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
}
},
])
}
}
struct StemPlaylistRow: View {
let playlist: StemPlaylist
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 14) {
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
VStack(alignment: .leading, spacing: 2) {
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
}
Spacer(minLength: 8)
Tag(text: "STEMS")
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
}
.frame(height: 76)
.contentShape(Rectangle())
.listRow(inset: 94, trailing: 16)
}
}
/// A stem playlist: lives on this device, and every song plays with its saved mix.
struct StemPlaylistScreen: View {
let id: String
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var editing = false
@State private var items: [StemPlaylistItem] = []
private var g: AppGraph { .shared }
var body: some View {
if let pl = g.stemPlaylists.list(id) {
page(pl)
} else {
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
}
}
private func page(_ pl: StemPlaylist) -> some View {
Group {
if editing {
List {
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
ForEach(items) { it in
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
}
.onDelete { items.remove(atOffsets: $0) }
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(p.bg)
.environment(\.editMode, .constant(.active))
.navigationTitle(pl.title)
.navigationBarTitleDisplayMode(.inline)
} else {
Page(title: pl.title, large: false) {
header(pl)
if pl.items.isEmpty {
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
}
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
}
.listRow(inset: MARGIN + 60)
}
}
}
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
if !editing { MoreMenu(items: moreItems(pl)) }
}
}
}
private func header(_ pl: StemPlaylist) -> some View {
VStack(spacing: 0) {
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
.padding(.horizontal, 32).padding(.top, 14)
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
if !editing && !pl.items.isEmpty {
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 8).padding(.bottom, 16)
}
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
let lists = g.stemPlaylists
let rename = SheetAction("Rename…", icon: "pencil") {
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
}
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
}
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
}
} }
struct PlaylistRow: View { struct PlaylistRow: View {
@@ -30,6 +30,32 @@ private struct ToggleCell: View {
} }
} }
/// How quiet karaoke's Soft vocals are.
private struct SoftVocalsCell: View {
@Environment(\.palette) private var p
private var g: AppGraph { .shared }
var body: some View {
let db = g.settings.karaokeSoftDb
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 14) {
SettingsIcon(symbol: "music.mic", bg: 0xFA2D48)
Text("Karaoke Soft Vocals").foregroundStyle(p.label)
Spacer()
Text("\(db) dB").monospacedDigit().foregroundStyle(p.label2)
}
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) {
Text("Karaoke Soft Vocals")
} minimumValueLabel: {
Text("Quieter").font(.caption).foregroundStyle(p.label2)
} maximumValueLabel: {
Text("Louder").font(.caption).foregroundStyle(p.label2)
}
}
.padding(.vertical, 4)
.themedRow()
}
}
/// A tappable row in the accent (or red), optionally centred. /// A tappable row in the accent (or red), optionally centred.
private struct ActionCell: View { private struct ActionCell: View {
let title: String let title: String
@@ -115,6 +141,7 @@ struct SettingsScreen: View {
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq) NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality) NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix) ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
SoftVocalsCell()
} header: { Header(text: "Sound") } } header: { Header(text: "Sound") }
Section { Section {
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
+5 -5
View File
@@ -1,13 +1,13 @@
name: Stemify name: Stemify
options: options:
bundleIdPrefix: com.stemify bundleIdPrefix: org.cdnutter
deploymentTarget: deploymentTarget:
iOS: "17.0" iOS: "17.0"
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.2" MARKETING_VERSION: "1.0.12"
CURRENT_PROJECT_VERSION: "3" CURRENT_PROJECT_VERSION: "13"
targets: targets:
Stemify: Stemify:
type: application type: application
@@ -15,7 +15,7 @@ targets:
sources: [Stemify] sources: [Stemify]
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.stemify.mobile PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
@@ -44,7 +44,7 @@ targets:
INIntentsSupported: [INPlayMediaIntent] INIntentsSupported: [INPlayMediaIntent]
INSupportedMediaCategories: [INMediaCategoryMusic] INSupportedMediaCategories: [INMediaCategoryMusic]
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: true UIApplicationSupportsMultipleScenes: false
UISceneConfigurations: UISceneConfigurations:
UIWindowSceneSessionRoleApplication: UIWindowSceneSessionRoleApplication:
- UISceneConfigurationName: Phone - UISceneConfigurationName: Phone
+35 -5
View File
@@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
# Builds an unsigned Stemify.ipa on a Mac, for re-signing with Sideloadly/AltStore. # Builds build/stemify-<version>.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore.
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed
# prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch. # Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -7,14 +9,17 @@ export PATH=/opt/homebrew/bin:$PATH
OUT=build; APP=$OUT/Payload/Stemify.app OUT=build; APP=$OUT/Payload/Stemify.app
rm -rf $OUT && mkdir -p $APP rm -rf $OUT && mkdir -p $APP
SDK=$(xcrun --sdk iphoneos --show-sdk-path) SDK=$(xcrun --sdk iphoneos --show-sdk-path)
# Version and build number come from project.yml.
VERSION=$(sed -nE 's/^ *MARKETING_VERSION: "(.*)"/\1/p' project.yml)
BUILD=$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: "(.*)"/\1/p' project.yml)
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \ xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \
-module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify -module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify
# Info.plist: xcodegen's generated one with the build settings filled in. # Info.plist: xcodegen's generated one with the build settings filled in.
xcodegen generate -q xcodegen generate -q
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/com.stemify.mobile/' \ sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/org.cdnutter.stemify/' \
-e 's/$(PRODUCT_NAME)/Stemify/; s/$(MARKETING_VERSION)/1.0.2/; s/$(CURRENT_PROJECT_VERSION)/3/' \ -e "s/\$(PRODUCT_NAME)/Stemify/; s/\$(MARKETING_VERSION)/$VERSION/; s/\$(CURRENT_PROJECT_VERSION)/$BUILD/" \
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist -e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
P=$APP/Info.plist P=$APP/Info.plist
plutil -insert MinimumOSVersion -string 17.0 $P plutil -insert MinimumOSVersion -string 17.0 $P
@@ -31,5 +36,30 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
(cd $OUT && zip -qry Stemify.ipa Payload) # Unsigned build, for the releases and AltStore/Sideloadly.
echo "$OUT/Stemify.ipa" (cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
echo "$OUT/stemify-$VERSION.ipa"
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
security create-keychain -p "$KP" $KC
security set-keychain-settings -lut 3600 $KC
security unlock-keychain -p "$KP" $KC
security import prov/key.p12 -P "$P12_PASS" -A -t agg -f pkcs12 -k $KC >/dev/null
# The dev cert chains to Apple's WWDR G3 intermediate, which a fresh keychain doesn't have.
[ -f prov/wwdr.cer ] || curl -fsSL -o prov/wwdr.cer https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
security import prov/wwdr.cer -A -k $KC >/dev/null 2>&1 || true
security set-key-partition-list -S apple-tool:,apple: -s -k "$KP" $KC >/dev/null
security list-keychains -d user -s $KC $(security list-keychains -d user | tr -d '"')
ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1)
security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist
cp prov/Stemify.mobileprovision $APP/embedded.mobileprovision
codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP
security list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain)
security delete-keychain $KC
# Personal build for this device only: stays in prov/ (gitignored), never released.
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)"
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Writes ios/source.json, an AltStore source (SideStore and Feather read it too), from the
ios-v* releases on Gitea. Only releases with a stemify-<version>.ipa are listed, since older
ones used a different bundle ID. Set GITEA_TOKEN if the repo is private."""
import json, os, re, urllib.request
REPO = "https://git.iamchrisama.com/cdnutter/stemify"
API = "https://git.iamchrisama.com/api/v1/repos/cdnutter/stemify"
RAW = f"{REPO}/raw/branch/main"
HERE = os.path.dirname(os.path.abspath(__file__))
def get(url):
req = urllib.request.Request(url)
if os.environ.get("GITEA_TOKEN"):
req.add_header("Authorization", "token " + os.environ["GITEA_TOKEN"])
with urllib.request.urlopen(req) as r:
return json.load(r)
yml = open(os.path.join(HERE, "..", "project.yml")).read()
# Build numbers: older ones from the last source.json, the current one from project.yml.
builds = {}
try:
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
builds[v["version"]] = v["buildVersion"]
except (OSError, ValueError, KeyError, IndexError):
pass
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
builds[m[0][1]] = m[1][1]
versions = []
for rel in get(API + "/releases?limit=50"):
v = rel["tag_name"].removeprefix("ios-v")
ipa = next((a for a in rel["assets"] if a["name"] == f"stemify-{v}.ipa"), None)
if not rel["tag_name"].startswith("ios-v") or not ipa:
continue
versions.append({
"version": v,
"buildVersion": builds.get(v, v),
"date": rel["published_at"],
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
"downloadURL": ipa["browser_download_url"],
"size": ipa["size"],
"minOSVersion": "17.0",
})
source = {
"name": "Stemify",
"identifier": "org.cdnutter.stemify.source",
"sourceURL": f"{RAW}/ios/source.json",
"iconURL": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"website": REPO,
"apps": [{
"name": "Stemify",
"bundleIdentifier": "org.cdnutter.stemify",
"developerName": "cdnutter",
"subtitle": "Plex music player with live stem mixing.",
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. "
"Works offline with downloads, and in the car with CarPlay.",
"iconURL": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"tintColor": "#FA2D48",
"category": "entertainment",
"versions": versions,
"appPermissions": {
"entitlements": ["com.apple.developer.carplay-audio", "com.apple.developer.siri"],
"privacy": {
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library.",
},
},
}],
"news": [],
}
with open(os.path.join(HERE, "..", "source.json"), "w") as f:
json.dump(source, f, indent=2)
f.write("\n")
print(f"source.json: {len(versions)} version(s)")
+113
View File
@@ -0,0 +1,113 @@
{
"name": "Stemify",
"identifier": "org.cdnutter.stemify.source",
"sourceURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json",
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"website": "https://git.iamchrisama.com/cdnutter/stemify",
"apps": [
{
"name": "Stemify",
"bundleIdentifier": "org.cdnutter.stemify",
"developerName": "cdnutter",
"subtitle": "Plex music player with live stem mixing.",
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. Works offline with downloads, and in the car with CarPlay.",
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"tintColor": "#FA2D48",
"category": "entertainment",
"versions": [
{
"version": "1.0.11",
"buildVersion": "12",
"date": "2026-10-20T12:24:42-07:00",
"localizedDescription": "Tapping the lock screen / Control Center player opens the app again.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.11/stemify-1.0.11.ipa",
"size": 1529241,
"minOSVersion": "17.0"
},
{
"version": "1.0.10",
"buildVersion": "11",
"date": "2026-10-20T11:21:34-07:00",
"localizedDescription": "Shuffle and repeat (list and song) on Now Playing. Playback speed from 0.5x to 2x, pitch kept.\n\nUnsigned IPA, not tested on device.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.10/stemify-1.0.10.ipa",
"size": 1529239,
"minOSVersion": "17.0"
},
{
"version": "1.0.9",
"buildVersion": "10",
"date": "2026-10-20T10:28:26-07:00",
"localizedDescription": "- Add to Playlist is now one sheet instead of a string of pop-ups: Plex and Stem tabs, type a new playlist name right in the list, playlist art, and mix chips for stem playlists.\n- Song menus have a single \"Add to Playlist\u2026\" entry for both kinds; the sheet remembers which tab you used last.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.9/stemify-1.0.9.ipa",
"size": 1518270,
"minOSVersion": "17.0"
},
{
"version": "1.0.8",
"buildVersion": "9",
"date": "2026-10-20T10:02:44-07:00",
"localizedDescription": "- Playlists tab now has Plex and Stem sections. Plex playlists are unchanged and use their custom Plex poster when there is one.\n- Stem playlists: each song keeps its own mix (instrumental, a cappella, no drums, custom...). Add songs from any menu, or copy a Plex playlist over in one go. Change one song's mix or the whole list's.\n- Softness slider under the Vocals picker in the lyrics view when vocals are set to Soft.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.8/stemify-1.0.8.ipa",
"size": 1462423,
"minOSVersion": "17.0"
},
{
"version": "1.0.7",
"buildVersion": "8",
"date": "2026-10-20T09:39:03-07:00",
"localizedDescription": "- Mini player now sits in the tab bar's Liquid Glass accessory on iOS 26.1 and later, like Music. The tab bar shrinks when you scroll down and the player collapses to title and play/pause. Older iOS keeps the floating card.\n- Settings > Sound > Karaoke Soft Vocals: set how quiet Soft vocals are (-30 to -3 dB, default -12).\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.7/stemify-1.0.7.ipa",
"size": 1390623,
"minOSVersion": "17.0"
},
{
"version": "1.0.6",
"buildVersion": "7",
"date": "2026-10-20T09:31:01-07:00",
"localizedDescription": "- Karaoke vocals option on the lyrics panel: On (full vocals), Soft (vocals about 12 dB down as a guide), Off (no vocals). Remembered between songs and launches.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.6/stemify-1.0.6.ipa",
"size": 1375443,
"minOSVersion": "17.0"
},
{
"version": "1.0.5",
"buildVersion": "6",
"date": "2026-10-20T09:23:39-07:00",
"localizedDescription": "- Sort menu on Artists, Albums, Songs and Playlists (Name, Date Added, Last Played, Plays, Rating, Year, Duration and more). Tap the current field again to flip direction (A to Z / Z to A, newest / oldest).\n- Each screen remembers its sort.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.5/stemify-1.0.5.ipa",
"size": 1370686,
"minOSVersion": "17.0"
},
{
"version": "1.0.4",
"buildVersion": "5",
"date": "2026-10-20T09:18:33-07:00",
"localizedDescription": "Save a stem mix for a song (Save chip in the mixer); it comes back every time that song plays. New mic button for karaoke mode: vocals muted on every song plus synced lyrics from LRCLIB, tap a line to jump to it.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.4/stemify-1.0.4.ipa",
"size": 1355610,
"minOSVersion": "17.0"
},
{
"version": "1.0.3",
"buildVersion": "4",
"date": "2026-10-20T09:08:18-07:00",
"localizedDescription": "Bundle ID is now org.cdnutter.stemify (delete the old app first; settings won't carry over). IPA is named stemify-<version>.ipa. Adds an AltStore/SideStore/Feather source.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa",
"size": 1319672,
"minOSVersion": "17.0"
}
],
"appPermissions": {
"entitlements": [
"com.apple.developer.carplay-audio",
"com.apple.developer.siri"
],
"privacy": {
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library."
}
}
}
],
"news": []
}
+5 -2
View File
@@ -1,8 +1,11 @@
# Build from the repo root so the web player comes along:
# docker build -f server/Dockerfile -t stemserve .
FROM python:3.12-alpine FROM python:3.12-alpine
RUN apk add --no-cache ffmpeg RUN apk add --no-cache ffmpeg
COPY stemserve.py /app/stemserve.py COPY server/stemserve.py /app/server/stemserve.py
COPY web /app/web
ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1 ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1
EXPOSE 8765 EXPOSE 8765
# Unraid shares belong to nobody:users. # Unraid shares belong to nobody:users.
USER 99:100 USER 99:100
CMD ["python", "/app/stemserve.py"] CMD ["python", "/app/server/stemserve.py"]
+6 -2
View File
@@ -15,8 +15,9 @@ from the desktop). From an unraid terminal:
```sh ```sh
mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
cp -r /path/to/stemify/server /mnt/user/appdata/stemserve/src # or copy over SMB mkdir -p /mnt/user/appdata/stemserve/src # needs server/ and web/ from the repo
docker build -t stemserve /mnt/user/appdata/stemserve/src cp -r /path/to/stemify/{server,web} /mnt/user/appdata/stemserve/src/ # or copy over SMB
docker build -f /mnt/user/appdata/stemserve/src/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve/src
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \ docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
-v /mnt/user/Music/Stems:/stems:ro \ -v /mnt/user/Music/Stems:/stems:ro \
-v /mnt/user/appdata/stemserve/cache:/cache \ -v /mnt/user/appdata/stemserve/cache:/cache \
@@ -42,6 +43,9 @@ curl http://192.168.86.2:8765/api/info
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` | | `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder | | `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) | | `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
| `PLEX_URL` | empty | Plex address; the web UI reaches it through `/plex/` on this server, so HTTPS pages work |
| `PLEX_TOKEN`| empty | Plex token for the web UI (set `TOKEN` too) |
| `WEB` | `1` | `0` turns off the web UI |
## API ## API
+3 -1
View File
@@ -1,6 +1,6 @@
services: services:
stemserve: stemserve:
build: . build: {context: .., dockerfile: server/Dockerfile}
image: stemserve:latest image: stemserve:latest
container_name: stemserve container_name: stemserve
restart: unless-stopped restart: unless-stopped
@@ -14,3 +14,5 @@ services:
environment: environment:
# Optional shared secret; set the same token in the app (Settings > Stem Server). # Optional shared secret; set the same token in the app (Settings > Stem Server).
- TOKEN= - TOKEN=
- PLEX_URL=
- PLEX_TOKEN=
+57
View File
@@ -16,6 +16,9 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support /file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
/plex/... proxy to PLEX_URL (needs TOKEN or the Plex token)
/api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
/ the web player (web/, or WEB_DIR); no token needed
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song". <key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
""" """
@@ -31,7 +34,9 @@ import subprocess
import sys import sys
import threading import threading
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request
from array import array from array import array
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http import HTTPStatus from http import HTTPStatus
@@ -43,6 +48,9 @@ PORT = int(os.environ.get("PORT", "8765"))
TOKEN = os.environ.get("TOKEN", "") TOKEN = os.environ.get("TOKEN", "")
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30")) INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
PLEX_URL = os.environ.get("PLEX_URL", "").rstrip("/")
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
SERVE_WEB = os.environ.get("WEB", "1") != "0" # WEB=0: API only, no web player
MAX_BODY = 8 << 20 MAX_BODY = 8 << 20
MARKER = ".stemify.json" MARKER = ".stemify.json"
VERSION = 1 VERSION = 1
@@ -50,6 +58,10 @@ FFMPEG = shutil.which("ffmpeg")
OPUS_RATES = (64, 96, 128, 160, 192, 256) OPUS_RATES = (64, 96, 128, 160, 192, 256)
PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars
CHUNK = 1 << 20 CHUNK = 1 << 20
# The web player. Served without the token; it asks for one in its settings when TOKEN is set.
WEB_DIR = os.path.realpath(os.environ.get("WEB_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web")))
WEB_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json"}
_pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2)) _pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2))
_locks: dict[str, threading.Lock] = {} _locks: dict[str, threading.Lock] = {}
@@ -254,6 +266,31 @@ class Handler(BaseHTTPRequestHandler):
return True return True
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0]) return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
def _plex_proxy(self, path, rawquery, query):
"""Forward to PLEX_URL so an HTTPS page can reach an HTTP Plex. Needs the stem or Plex token."""
given = self.headers.get("X-Plex-Token") or query.get("X-Plex-Token", [None])[0]
if not (self._authorized(query) or (PLEX_TOKEN and given == PLEX_TOKEN)):
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
pairs = [(k, v) for k, v in urllib.parse.parse_qsl(rawquery, keep_blank_values=True) if k not in ("token", "X-Plex-Token")]
req = urllib.request.Request(PLEX_URL + path + ("?" + urllib.parse.urlencode(pairs) if pairs else ""))
req.add_header("X-Plex-Token", PLEX_TOKEN or given or "")
for h in ("Range", "Accept", "X-Plex-Client-Identifier", "X-Plex-Product", "If-None-Match"):
if self.headers.get(h):
req.add_header(h, self.headers[h])
try:
r = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e:
r = e
except OSError as e:
return self._error(502, f"plex unreachable: {e}")
self.send_response(r.status if hasattr(r, "status") else r.code)
for h in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"):
if r.headers.get(h):
self.send_header(h, r.headers[h])
self.end_headers()
while chunk := r.read(65536):
self.wfile.write(chunk)
def _send(self, status, body=b"", ctype="application/json", headers=None): def _send(self, status, body=b"", ctype="application/json", headers=None):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
@@ -267,6 +304,19 @@ class Handler(BaseHTTPRequestHandler):
def _error(self, status, msg): def _error(self, status, msg):
self._send(status, json.dumps({"error": msg}).encode()) self._send(status, json.dumps({"error": msg}).encode())
def _web(self, path):
if not SERVE_WEB:
return self._error(404, "not found")
if path == "/":
path = "/index.html"
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
if not full.startswith(WEB_DIR + os.sep) or not os.path.isfile(full):
return self._error(404, "not found")
with open(full, "rb") as f:
body = f.read()
ctype = WEB_TYPES.get(os.path.splitext(full)[1], "application/octet-stream")
self._send(200, body, ctype, {"Cache-Control": "no-cache"})
def do_HEAD(self): def do_HEAD(self):
self.do_GET() self.do_GET()
@@ -277,6 +327,10 @@ class Handler(BaseHTTPRequestHandler):
url = urllib.parse.urlsplit(self.path) url = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(url.query) query = urllib.parse.parse_qs(url.query)
path = urllib.parse.unquote(url.path) path = urllib.parse.unquote(url.path)
if path.startswith("/plex/") and PLEX_URL:
return self._plex_proxy(path[len("/plex"):], url.query, query)
if not path.startswith(("/api/", "/file/")):
return self._web(path)
if not self._authorized(query): if not self._authorized(query):
return self._error(HTTPStatus.UNAUTHORIZED, "bad token") return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
try: try:
@@ -286,6 +340,9 @@ class Handler(BaseHTTPRequestHandler):
"name": "stemserve", "version": VERSION, "tracks": INDEX.count, "name": "stemserve", "version": VERSION, "tracks": INDEX.count,
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [], "ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode()) "stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
if path == "/api/config":
plex = {"url": PLEX_URL, "token": PLEX_TOKEN} if PLEX_URL and PLEX_TOKEN else None
return self._send(200, json.dumps({"plex": plex}).encode(), headers={"Cache-Control": "no-store"})
if path == "/api/index": if path == "/api/index":
body, etag = INDEX.get() body, etag = INDEX.get()
if self.headers.get("If-None-Match") == etag: if self.headers.get("If-None-Match") == etag:
+874
View File
@@ -0,0 +1,874 @@
// Stemify web player. Browses Plex, plays stems from stemserve, no build step.
// Each stem is its own <audio> element so speed changes keep pitch; a tick keeps them lined up.
const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const shuffled = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const ICON = {
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',
next: '<svg viewBox="0 0 24 24"><rect x="17.5" y="5" width="2.5" height="14" rx="1"/><path d="M4 5.7v12.6a1 1 0 0 0 1.55.83L15 12.83a1 1 0 0 0 0-1.66l-9.45-6.3A1 1 0 0 0 4 5.7z"/></svg>',
shuffle: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4V8h-2.2c-1 0-1.9.5-2.5 1.3L7.6 16c-.9 1.3-2.4 2-4 2H2v-2h1.6c.9 0 1.8-.5 2.3-1.2l4.7-6.7C11.5 6.8 13 6 14.8 6H17V3zM2 6h1.6c1.6 0 3.1.8 4 2l.9 1.3-1.2 1.7-1.4-1.9A2.8 2.8 0 0 0 3.6 8H2V6zm12.3 9.7c.6.8 1.5 1.3 2.5 1.3H17v-3l4 4-4 4v-3h-.2c-1.7 0-3.3-.8-4.2-2.2l-.6-.8 1.2-1.7.1.4z"/></svg>',
repeat: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/></svg>',
repeat1: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/><path d="M11.2 9.6l1.8-.9h1.2v6.6h-1.6v-4.9l-1.4.6z"/></svg>',
mixer: '<svg viewBox="0 0 24 24"><path d="M6 3h2v6.2a3 3 0 0 1 0 5.6V21H6v-6.2a3 3 0 0 1 0-5.6V3zm10 0h2v2.2a3 3 0 0 1 0 5.6V21h-2V10.8a3 3 0 0 1 0-5.6V3zM7 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>',
queue: '<svg viewBox="0 0 24 24"><path d="M3 5h14v2H3zm0 6h14v2H3zm0 6h9v2H3zm13-1.5v-1.5l6 3.5-6 3.5V17z"/></svg>',
busy: '<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2.2A6.8 6.8 0 1 1 12 5.2V3z"/></svg>',
};
// ---- settings --------------------------------------------------------------------------
const KEY = "stemify-web";
const S = Object.assign({
plex: null, // {url, token, name}
section: null,
stemUrl: "", // empty = same origin as this page
stemToken: "",
fmt: "opus160",
volume: 0.85, speed: 1, shuffle: false, repeat: "off",
mix: {}, // stem name -> {level, muted, solo}; kept across tracks
albumSort: "titleSort",
}, load(KEY) || {});
if (!S.clientId) S.clientId = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2);
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
let saveTimer;
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(KEY, JSON.stringify(S));
localStorage.setItem(KEY + "-queue", JSON.stringify({ items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }));
} catch {}
}, 300);
}
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
// ---- Plex ------------------------------------------------------------------------------
const plexHeaders = token => ({
Accept: "application/json",
"X-Plex-Product": "Stemify Web",
"X-Plex-Client-Identifier": S.clientId,
...(token ? { "X-Plex-Token": token } : {}),
});
async function px(path, params = {}) {
if (!S.plex) throw new Error("Not signed in to Plex");
const u = new URL(S.plex.url + path, location.href);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
if (!r.ok) throw new Error(`Plex ${r.status}`);
return (await r.json()).MediaContainer || {};
}
const plexImg = (thumb, size = 300) => thumb && S.plex
? `${S.plex.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${S.plex.token}`
: "";
function plexTrack(m) {
const part = m.Media?.[0]?.Part?.[0];
return {
id: "p" + m.ratingKey, title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
albumKey: m.parentRatingKey, thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
part: part?.key, file: part?.file, index: m.index,
};
}
async function plexSignIn(status) {
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: plexHeaders() })).json();
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
window.open(auth, "_blank", "width=800,height=740");
status("Waiting for Plex sign-in…");
let token = null;
for (let n = 0; n < 150 && !token; n++) {
await new Promise(r => setTimeout(r, 2000));
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: plexHeaders() })).json()).authToken;
}
if (!token) throw new Error("Sign-in timed out");
status("Finding your server…");
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: plexHeaders(token) })).json();
const https = location.protocol === "https:";
for (const srv of res.filter(r => r.provides?.includes("server"))) {
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
for (const c of conns) {
try {
const r = await fetch(`${c.uri}/identity`, { headers: plexHeaders(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
if (r.ok) return { url: c.uri, token: srv.accessToken || token, name: srv.name };
} catch {}
}
}
throw new Error("Signed in, but no Plex server answered from this browser");
}
async function musicSection() {
if (S.section) return S.section;
const secs = (await px("/library/sections")).Directory || [];
S.section = secs.find(d => d.type === "artist")?.key || null;
save();
if (!S.section) throw new Error("No music library on this Plex server");
return S.section;
}
// ---- stemserve -------------------------------------------------------------------------
const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
const stemQuery = extra => {
const q = new URLSearchParams(extra);
if (S.stemToken) q.set("token", S.stemToken);
const s = q.toString();
return s ? "?" + s : "";
};
const keyPath = k => k.split("/").map(encodeURIComponent).join("/");
const fmtParams = () => S.fmt.startsWith("opus") ? { fmt: "opus", br: S.fmt.slice(4) } : {};
// Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's
// suffix identifies a track. Short or duration-mismatched suffixes only count under a root
// learned from an earlier confident match.
const Stems = {
entries: [], bySource: new Map(), roots: [], etag: null, ok: false,
norm(p) {
p = p.normalize("NFC").replaceAll("\\", "/");
const slash = p.lastIndexOf("/"), dot = p.lastIndexOf(".");
if (dot > slash + 1) p = p.slice(0, dot);
return p.toLowerCase();
},
async refresh() {
try {
const r = await fetch(stemBase() + "/api/index" + stemQuery(), { headers: this.etag ? { "If-None-Match": this.etag } : {} });
this.ok = r.ok || r.status === 304;
if (r.status !== 200) return false;
this.etag = r.headers.get("ETag");
this.entries = (await r.json()).tracks || [];
this.bySource = new Map();
for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); }
return true;
} catch { this.ok = false; return false; }
},
find(t) {
if (t.stem) return t.stem;
if (!t.file || !this.entries.length) return null;
const path = this.norm(t.file);
for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; }
const parts = path.split("/");
for (let i = 1; i < parts.length; i++) {
const hit = this.bySource.get(parts.slice(i).join("/"));
if (!hit) continue;
const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000;
if (parts.length - i >= 2 && okDur) {
const root = parts.slice(0, i).join("/") + "/";
if (!this.roots.includes(root)) this.roots.push(root);
return hit;
}
}
return null;
},
url(e, s) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams())}`; },
prepare(e) {
if (e && S.fmt.startsWith("opus")) fetch(`${stemBase()}/api/prepare${stemQuery({ key: e.key, ...fmtParams() })}`, { method: "POST" }).catch(() => {});
},
};
const stemTrack = e => {
const parts = e.key.split("/");
return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e };
};
// ---- mix -------------------------------------------------------------------------------
const unity = () => ({ level: 1, muted: false, solo: false });
const PRESETS = [
["Full Mix", () => ({})],
["Karaoke", n => n.includes("vocals") && { vocals: { muted: true } }],
["A Cappella", n => n.includes("vocals") && { vocals: { solo: true } }],
["No Drums", n => n.includes("drums") && { drums: { muted: true } }],
["Rhythm", n => n.includes("drums") && n.includes("bass") && { drums: { solo: true }, bass: { solo: true } }],
["No Guitar", n => n.includes("guitar") && { guitar: { muted: true } }],
["No Bass", n => n.includes("bass") && { bass: { muted: true } }],
];
const setting = name => ({ ...unity(), ...S.mix[name] });
function stemGain(name, names) {
const s = setting(name), anySolo = names.some(n => setting(n).solo);
return anySolo ? (s.solo ? s.level : 0) : (s.muted ? 0 : s.level);
}
function applyPreset(build, names) {
const m = build(names);
if (!m) return;
S.mix = {};
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
mixChanged();
}
function currentPreset(names) {
const norm = m => JSON.stringify(names.map(n => { const s = { ...unity(), ...m[n] }; return [s.level, s.muted, s.solo]; }));
const now = norm(S.mix);
return PRESETS.find(([, b]) => { const m = b(names); return m && norm(Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { ...unity(), ...v }]))) === now; })?.[0];
}
// ---- audio -----------------------------------------------------------------------------
const E = {
els: [], names: [], gains: [], ctx: null, master: null, graph: false,
want: false, stalled: false, onEnded: null,
audioCtx() {
if (!this.ctx) {
const C = window.AudioContext || window.webkitAudioContext;
if (!C) return null;
this.ctx = new C();
this.master = this.ctx.createGain();
this.master.connect(this.ctx.destination);
this.master.gain.value = S.volume;
}
if (this.ctx.state === "suspended") this.ctx.resume();
return this.ctx;
},
load(sources, startSec, play) {
this.unload();
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
const sameOrigin = sources.every(s => new URL(s.url, location.href).origin === location.origin);
this.graph = sameOrigin && !!this.audioCtx();
for (const s of sources) {
const el = new Audio();
el.preload = "auto";
el.preservesPitch = true;
el.src = s.url;
el.playbackRate = el.defaultPlaybackRate = S.speed;
if (startSec) el.currentTime = startSec;
let gain = null;
if (this.graph) {
gain = this.ctx.createGain();
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
}
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
}
const lead = this.els[0];
lead.addEventListener("ended", () => this.onEnded?.());
lead.addEventListener("durationchange", () => UI.progress());
lead.addEventListener("error", () => { if (this.want) toast("Couldn't load this track"); });
this.applyGains();
this.want = play;
this.stalled = play;
},
unload() {
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
this.els = []; this.names = []; this.gains = [];
},
applyGains() {
const named = this.names.filter(Boolean);
this.els.forEach((el, i) => {
const g = this.names[i] ? stemGain(this.names[i], named) : 1;
if (this.gains[i]) this.gains[i].gain.value = g;
else el.volume = Math.min(1, g * (this.graph ? 1 : S.volume));
});
if (this.master) this.master.gain.value = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); },
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); },
seek(sec) { this.els.forEach(e => { e.currentTime = sec; }); if (this.want) this.stalled = true; UI.progress(); },
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
position() { return this.els[0]?.currentTime || 0; },
duration() { const d = this.els[0]?.duration; return isFinite(d) ? d : (Q.cur()?.duration || 0) / 1000; },
get playing() { return this.want; },
get buffering() { return this.want && this.stalled; },
// Holds everything until every stem can play, then starts them together and keeps followers
// within ~15 ms of the lead by nudging their rate (pitch is preserved, so it's inaudible).
tick() {
if (!this.els.length || !this.want) return;
const lead = this.els[0];
const notReady = this.els.some(e => e.readyState < 3 && !e.ended);
if (notReady) {
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; UI.transport(); }
return;
}
if (this.stalled) {
this.stalled = false;
const t = lead.currentTime;
this.els.forEach(e => { if (e !== lead && Math.abs(e.currentTime - t) > 0.01) e.currentTime = t; e.playbackRate = S.speed; });
Promise.all(this.els.map(e => e.play())).catch(err => {
if (err.name === "NotAllowedError") { this.pause(); UI.transport(); }
});
UI.transport();
return;
}
for (const f of this.els.slice(1)) {
if (f.ended || lead.ended) continue;
const d = f.currentTime - lead.currentTime;
if (Math.abs(d) > 0.25) f.currentTime = lead.currentTime;
else if (Math.abs(d) > 0.015) f.playbackRate = S.speed * (1 - Math.max(-0.06, Math.min(0.06, d * 1.5)));
else if (f.playbackRate !== S.speed) f.playbackRate = S.speed;
}
},
};
setInterval(() => E.tick(), 150);
// ---- queue -----------------------------------------------------------------------------
let uidSeq = Date.now();
const wrap = t => ({ uid: uidSeq++, t });
const Q = {
items: [], orig: null, i: -1,
cur() { return this.items[this.i]?.t; },
playList(tracks, start = 0, shuffle = S.shuffle) {
if (!tracks.length) return;
const all = tracks.map(wrap);
if (shuffle) {
const first = all[start] && start >= 0 ? all[start] : all[Math.floor(Math.random() * all.length)];
this.orig = all;
this.items = [first, ...shuffled(all.filter(x => x !== first))];
this.i = 0;
} else { this.orig = null; this.items = all; this.i = start; }
S.shuffle = shuffle;
this.start(0, true);
},
add(tracks, next = false) {
const w = tracks.map(wrap);
if (next) { this.items.splice(this.i + 1, 0, ...w); this.orig?.push(...w); }
else { this.items.push(...w); this.orig?.push(...w); }
if (this.i < 0) { this.i = 0; this.start(0, false); }
UI.drawer(); save();
toast(tracks.length === 1 ? `Added “${tracks[0].title}”` : `Added ${tracks.length} tracks`);
},
remove(idx) {
if (idx === this.i) return;
const [x] = this.items.splice(idx, 1);
if (this.orig) this.orig = this.orig.filter(o => o !== x);
if (idx < this.i) this.i--;
UI.drawer(); save();
},
jump(idx) { this.i = idx; this.start(0, true); },
start(posSec, play) {
const t = this.cur();
if (!t) { E.unload(); UI.all(); return; }
const e = Stems.find(t);
const srcs = e
? e.stems.map(s => ({ name: s.name, url: Stems.url(e, s) }))
: [{ name: null, url: `${S.plex?.url}${t.part}?X-Plex-Token=${S.plex?.token}` }];
E.load(srcs, posSec, play);
Wave.load(e);
Stems.prepare(e);
const nt = this.items[this.i + 1]?.t;
if (nt) Stems.prepare(Stems.find(nt));
UI.all(); save();
},
next(auto = false) {
if (auto && S.repeat === "one") { E.seek(0); E.play(); return; }
if (this.i + 1 < this.items.length) { this.i++; this.start(0, true); }
else if (S.repeat !== "off" && this.items.length) { this.i = 0; this.start(0, true); }
else if (auto) { E.pause(); E.seek(0); UI.transport(); }
},
prev() {
if (E.position() > 3 || (this.i === 0 && S.repeat === "off")) { E.seek(0); return; }
this.i = this.i > 0 ? this.i - 1 : this.items.length - 1;
this.start(0, E.playing);
},
toggleShuffle() {
if (!S.shuffle) {
this.orig = this.items.slice();
this.items = [...this.items.slice(0, this.i + 1), ...shuffled(this.items.slice(this.i + 1))];
} else if (this.orig) {
const cur = this.items[this.i];
this.items = this.orig;
this.i = Math.max(0, this.items.indexOf(cur));
this.orig = null;
}
S.shuffle = !S.shuffle;
UI.transport(); UI.drawer(); save();
},
cycleRepeat() {
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
UI.transport(); save();
},
};
E.onEnded = () => Q.next(true);
function setSpeed(v) {
S.speed = v;
E.setSpeed(v);
UI.transport(); save();
}
function mixChanged() { E.applyGains(); Wave.draw(); UI.drawer(); save(); }
// ---- waveform --------------------------------------------------------------------------
const Wave = {
peaks: null, key: null, canvas: $("#wave"),
async load(e) {
this.peaks = null; this.key = e?.key || null;
this.draw();
if (!e) return;
try {
const r = await fetch(`${stemBase()}/api/peaks${stemQuery({ key: e.key, n: 300 })}`);
if (!r.ok || this.key !== e.key) return;
this.peaks = (await r.json()).stems;
this.draw();
} catch {}
},
draw() {
const c = this.canvas, dpr = devicePixelRatio || 1;
const w = c.clientWidth, h = c.clientHeight;
if (!w) return;
if (c.width !== w * dpr) { c.width = w * dpr; c.height = h * dpr; }
const x = c.getContext("2d");
x.setTransform(dpr, 0, 0, dpr, 0, 0);
x.clearRect(0, 0, w, h);
const dur = E.duration(), frac = dur ? Math.min(1, E.position() / dur) : 0;
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent");
if (!this.peaks) {
x.fillStyle = "#ffffff22"; x.fillRect(0, h / 2 - 2, w, 4);
x.fillStyle = accent; x.fillRect(0, h / 2 - 2, w * frac, 4);
return;
}
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
const full = new Float32Array(n), mixed = new Float32Array(n);
for (const nm of names) {
const g = stemGain(nm, names);
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
}
const max = Math.sqrt(Math.max(...full)) || 1;
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
for (let b = 0; b < bars; b++) {
const i = Math.floor(b / bars * n);
const v = Math.sqrt(mixed[i]) / max, ghost = Math.sqrt(full[i]) / max;
const cx = b * bw;
x.fillStyle = "#ffffff12";
const gh = Math.max(2, ghost * h);
x.fillRect(cx, (h - gh) / 2, bw - 1, gh);
x.fillStyle = b / bars < frac ? accent : "#ffffff40";
const bh = Math.max(2, v * h);
x.fillRect(cx, (h - bh) / 2, bw - 1, bh);
}
},
};
{
let drag = false;
const at = ev => { const r = Wave.canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) * E.duration(); };
Wave.canvas.addEventListener("pointerdown", ev => { drag = true; Wave.canvas.setPointerCapture(ev.pointerId); E.seek(at(ev)); });
Wave.canvas.addEventListener("pointermove", ev => { if (drag) E.seek(at(ev)); });
Wave.canvas.addEventListener("pointerup", () => { drag = false; save(); });
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
}
// ---- player UI -------------------------------------------------------------------------
let drawerMode = null; // "mixer" | "queue"
const UI = {
all() { this.track(); this.transport(); this.progress(); this.drawer(); markPlaying(); },
track() {
const t = Q.cur();
$("#player").classList.toggle("empty", !t);
$("#np-title").textContent = t?.title || "Nothing playing";
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
const art = t ? plexImg(t.thumb, 120) : "";
if (art) $("#np-art").src = art; else $("#np-art").removeAttribute("src");
document.title = t ? `${t.title} · Stemify` : "Stemify";
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = t ? new MediaMetadata({
title: t.title, artist: t.artist, album: t.album,
artwork: t.thumb ? [{ src: plexImg(t.thumb, 512), sizes: "512x512" }] : [],
}) : null;
}
},
transport() {
const p = $("#b-play");
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
p.classList.toggle("busy", E.buffering);
$("#b-shuffle").classList.toggle("on", S.shuffle);
$("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat;
$("#b-repeat").classList.toggle("on", S.repeat !== "off");
$("#b-repeat").title = `Repeat: ${S.repeat} (R)`;
$("#speed").value = String(S.speed);
$("#speed").classList.toggle("on", S.speed !== 1);
$("#b-mixer").classList.toggle("on", drawerMode === "mixer");
$("#b-queue").classList.toggle("on", drawerMode === "queue");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
},
progress() {
$("#t-pos").textContent = fmtTime(E.position());
$("#t-dur").textContent = fmtTime(E.duration());
Wave.draw();
const d = E.duration();
if (d && navigator.mediaSession?.setPositionState) {
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
}
},
drawer() {
const d = $("#drawer");
d.hidden = !drawerMode;
if (drawerMode === "mixer") d.innerHTML = mixerHtml();
else if (drawerMode === "queue") d.innerHTML = queueHtml();
this.transport();
},
};
function mixerHtml() {
const names = E.names.filter(Boolean);
if (!names.length) return `<h2>Mixer</h2><p class="note">${Q.cur() ? "This track has no stems yet." : "Play something with stems to mix it."}</p>`;
const cur = currentPreset(names);
return `<h2>Mixer</h2>
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
${names.map(n => {
const s = setting(n), g = stemGain(n, names);
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
}).join("")}
<p class="note">The mix carries over to the next track.</p>`;
}
function queueHtml() {
if (!Q.items.length) return `<h2>Queue</h2><p class="note">Empty.</p>`;
return `<h2>Queue</h2>${Q.items.map((x, i) => `
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}">
<img loading="lazy" ${x.t.thumb ? `src="${esc(plexImg(x.t.thumb, 80))}"` : ""} alt="">
<div class="tx"><div>${esc(x.t.title)}</div><div>${esc(x.t.artist)}</div></div>
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
</div>`).join("")}`;
}
$("#drawer").addEventListener("click", ev => {
const b = ev.target.closest("button, .q-item");
if (!b) return;
const names = E.names.filter(Boolean);
if (b.dataset.preset) applyPreset(PRESETS[+b.dataset.preset][1], names);
else if (b.dataset.mute) { const n = b.dataset.mute; S.mix[n] = { ...setting(n), muted: !setting(n).muted }; mixChanged(); }
else if (b.dataset.solo) { const n = b.dataset.solo; S.mix[n] = { ...setting(n), solo: !setting(n).solo }; mixChanged(); }
else if (b.dataset.qx) Q.remove(+b.dataset.qx);
else if (b.dataset.q) Q.jump(+b.dataset.q);
});
$("#drawer").addEventListener("input", ev => {
const n = ev.target.dataset.level;
if (!n) return;
S.mix[n] = { ...setting(n), level: +ev.target.value };
E.applyGains(); Wave.draw(); save();
});
$("#drawer").addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); });
$("#b-play").innerHTML = ICON.play;
$("#b-prev").innerHTML = ICON.prev;
$("#b-next").innerHTML = ICON.next;
$("#b-shuffle").innerHTML = ICON.shuffle;
$("#b-mixer").innerHTML = ICON.mixer;
$("#b-queue").innerHTML = ICON.queue;
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
$("#volume").value = S.volume;
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); UI.transport(); save(); };
const toggleDrawer = m => { drawerMode = drawerMode === m ? null : m; UI.drawer(); };
$("#b-play").onclick = togglePlay;
$("#b-prev").onclick = () => Q.prev();
$("#b-next").onclick = () => Q.next();
$("#b-shuffle").onclick = () => Q.toggleShuffle();
$("#b-repeat").onclick = () => Q.cycleRepeat();
$("#b-mixer").onclick = () => toggleDrawer("mixer");
$("#b-queue").onclick = () => toggleDrawer("queue");
$("#speed").onchange = ev => setSpeed(+ev.target.value);
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler("play", () => { E.play(); UI.transport(); });
ms.setActionHandler("pause", () => { E.pause(); UI.transport(); });
ms.setActionHandler("previoustrack", () => Q.prev());
ms.setActionHandler("nexttrack", () => Q.next());
try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {}
}
document.addEventListener("keydown", ev => {
if (ev.target.closest("input, select, textarea") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
const k = ev.key.toLowerCase();
if (k === " ") { ev.preventDefault(); togglePlay(); }
else if (k === "arrowright") E.seek(Math.min(E.duration(), E.position() + (ev.shiftKey ? 30 : 5)));
else if (k === "arrowleft") E.seek(Math.max(0, E.position() - (ev.shiftKey ? 30 : 5)));
else if (k === "s") Q.toggleShuffle();
else if (k === "r") Q.cycleRepeat();
else if (k === "m") toggleDrawer("mixer");
else if (k === "q") toggleDrawer("queue");
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
});
(function frame() {
if (E.playing) {
$("#t-pos").textContent = fmtTime(E.position());
Wave.draw();
}
requestAnimationFrame(frame);
})();
setInterval(() => { if (E.playing) { UI.progress(); save(); } }, 5000);
function toast(msg) {
let t = $("#toast");
if (!t) {
t = document.createElement("div");
t.id = "toast";
t.style.cssText = "position:fixed;left:50%;bottom:calc(var(--player-h) + 16px);transform:translateX(-50%);background:var(--raised);padding:9px 16px;border-radius:999px;box-shadow:0 8px 30px #0008;z-index:9;transition:opacity .3s";
document.body.append(t);
}
t.textContent = msg;
t.style.opacity = 1;
clearTimeout(t._h);
t._h = setTimeout(() => { t.style.opacity = 0; }, 2200);
}
// ---- views -----------------------------------------------------------------------------
const view = $("#view");
let lists = {}; // track lists rendered in the current view, by id
let routeSeq = 0;
function markPlaying() {
const id = Q.cur()?.id;
view.querySelectorAll("tr[data-id]").forEach(r => r.classList.toggle("playing", r.dataset.id === id));
}
function card(href, img, t, s, round = false) {
return `<a class="card ${round ? "round" : ""}" href="${href}">${img ? `<img loading="lazy" src="${esc(img)}" alt="">` : `<div class="ph"></div>`}
<div class="t">${esc(t)}</div><div class="s">${esc(s || "")}</div></a>`;
}
function trackTable(listId, tracks, { numbers = false, album = true } = {}) {
lists[listId] = tracks;
return `<table class="tracks"><tbody>${tracks.map((t, i) => `
<tr data-list="${listId}" data-i="${i}" data-id="${esc(t.id)}">
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
<td class="ti">${esc(t.title)}${Stems.find(t) ? '<span class="stem-dot">STEMS</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
<td class="d">${fmtTime(t.duration / 1000)}</td>
<td><button class="add" data-add="${i}" title="Add to queue (shift: play next)">+</button></td>
</tr>`).join("")}</tbody></table>`;
}
view.addEventListener("click", ev => {
const add = ev.target.closest("[data-add]");
const row = ev.target.closest("tr[data-list]");
if (add && row) { Q.add([lists[row.dataset.list][+add.dataset.add]], ev.shiftKey); return; }
if (row) { Q.playList(lists[row.dataset.list], +row.dataset.i, false); return; }
const act = ev.target.closest("[data-play]");
if (act) {
const tracks = lists[act.dataset.play];
if (act.dataset.mode === "queue") Q.add(tracks);
else Q.playList(tracks, act.dataset.mode === "shuffle" ? -1 : 0, act.dataset.mode === "shuffle");
}
});
const playButtons = id => `<div class="actions">
<button class="btn primary" data-play="${id}">${ICON.play} Play</button>
<button class="btn" data-play="${id}" data-mode="shuffle">${ICON.shuffle} Shuffle</button>
<button class="btn" data-play="${id}" data-mode="queue">${ICON.queue} Add to queue</button></div>`;
function needPlex() {
view.innerHTML = `<h1>Welcome</h1><p class="sub">Sign in to Plex in <a href="#/settings" style="color:var(--accent)">Settings</a> to browse your library, or open <a href="#/stems" style="color:var(--accent)">Stems only</a>.</p>`;
}
// Grid of Plex items, loaded 120 at a time as you scroll.
async function pagedGrid(seq, path, params, cardFn) {
const grid = $(".grid", view), more = document.createElement("div");
more.className = "more";
grid.after(more);
let start = 0, total = Infinity, busy = false;
const next = async () => {
if (busy || start >= total || seq !== routeSeq) return;
busy = true;
const mc = await px(path, { ...params, "X-Plex-Container-Start": start, "X-Plex-Container-Size": 120 });
if (seq !== routeSeq) return;
total = mc.totalSize ?? mc.size ?? 0;
const items = mc.Metadata || [];
start += items.length;
if (!items.length) total = start;
grid.insertAdjacentHTML("beforeend", items.map(cardFn).join(""));
if (!start) grid.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
busy = false;
if (more.getBoundingClientRect().top < innerHeight * 1.5) next();
};
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
await next();
}
const albumCard = m => card(`#/album/${m.ratingKey}`, plexImg(m.thumb), m.title, [m.parentTitle, m.year].filter(Boolean).join(" · "));
const routes = {
async albums(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Albums</h1>
<select id="sort">${[["titleSort", "Title"], ["addedAt:desc", "Recently added"], ["year:desc", "Year"], ["artist.titleSort", "Artist"]]
.map(([v, l]) => `<option value="${v}" ${S.albumSort === v ? "selected" : ""}>${l}</option>`).join("")}</select></div><div class="grid"></div>`;
$("#sort").onchange = ev => { S.albumSort = ev.target.value; save(); route(); };
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 9, sort: S.albumSort }, albumCard);
},
async artists(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Artists</h1></div><div class="grid"></div>`;
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 8, sort: "titleSort" },
m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true));
},
async playlists() {
if (!S.plex) return needPlex();
const pls = (await px("/playlists", { playlistType: "audio" })).Metadata || [];
view.innerHTML = `<div class="bar"><h1>Playlists</h1></div>` + (pls.length
? `<div class="grid">${pls.map(p => card(`#/playlist/${p.ratingKey}`, plexImg(p.composite || p.thumb), p.title, `${p.leafCount} tracks`)).join("")}</div>`
: `<p class="empty-msg">No music playlists.</p>`);
},
async album(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {}, tracks = (kids.Metadata || []).map(plexTrack);
const stems = tracks.filter(t => Stems.find(t)).length;
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Album</div><h1>${esc(a.title)}</h1>
<div class="sub"><a href="#/artist/${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${tracks.length} tracks${stems ? ` · ${stems} with stems` : ""}</div>
${playButtons("album")}</div></div>${trackTable("album", tracks, { numbers: true, album: false })}`;
},
async artist(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {};
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="" style="border-radius:50%">
<div class="meta"><div class="kind">Artist</div><h1>${esc(a.title)}</h1>${playButtons("artist")}</div></div>
<h2>Albums</h2><div class="grid">${(kids.Metadata || []).map(albumCard).join("")}</div>`;
lists.artist = [];
const all = await px(`/library/metadata/${key}/allLeaves`);
if (seq === routeSeq) lists.artist = (all.Metadata || []).map(plexTrack);
},
async playlist(seq, key) {
const [info, items] = await Promise.all([px(`/playlists/${key}`), px(`/playlists/${key}/items`)]);
const p = info.Metadata?.[0] || {}, tracks = (items.Metadata || []).map(plexTrack);
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(p.composite || p.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Playlist</div><h1>${esc(p.title)}</h1><div class="sub">${tracks.length} tracks</div>${playButtons("playlist")}</div></div>
${trackTable("playlist", tracks)}`;
},
async search(seq, q) {
if (!S.plex) return needPlex();
const hubs = (await px("/hubs/search", { query: q, limit: 12, sectionId: await musicSection() })).Hub || [];
const hub = t => hubs.find(h => h.type === t)?.Metadata || [];
const artists = hub("artist"), albums = hub("album"), tracks = hub("track").map(plexTrack);
view.innerHTML = `<div class="bar"><h1>“${esc(q)}”</h1></div>` +
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true)).join("")}</div>` : "") +
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
(tracks.length ? `<h2>Songs</h2>${trackTable("search", tracks)}` : "") +
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
},
async stems(seq) {
if (!Stems.entries.length) await Stems.refresh();
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
view.innerHTML = `<div class="bar"><h1>Stems only</h1><input type="text" id="sf" placeholder="Filter ${all.length} tracks"></div><div id="sl"></div>`;
const render = () => {
const f = $("#sf").value.toLowerCase();
const hits = f ? all.filter(t => t.stem.key.toLowerCase().includes(f)) : all;
$("#sl").innerHTML = (hits.length ? trackTable("stems", hits.slice(0, 400)) : `<p class="empty-msg">${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}</p>`) +
(hits.length > 400 ? `<p class="note">Showing 400 of ${hits.length}. Filter to narrow it down.</p>` : "");
markPlaying();
};
$("#sf").oninput = render;
render();
},
async settings() {
const info = await fetch(stemBase() + "/api/info" + stemQuery()).then(r => r.ok ? r.json() : null).catch(() => null);
let secs = [];
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form">
<p class="note">${S.plex ? `Plex: <b>${esc(S.plex.env ? "via this server" : S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}</b>${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}</p>
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400" value="${esc(S.plex?.url || "")}"></label>
<label>Token<input type="password" id="ptok" value="${esc(S.plex?.token || "")}"></label>
<div class="row"><button class="btn" id="psave">Save</button></div></div></details>
</div>
<h2>Stem server</h2><div class="form">
<p class="note">${info ? `stemserve v${info.version}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : "Can't reach stemserve."}</p>
<label>URL (blank = this page's server)<input type="url" id="surl" placeholder="${esc(location.origin)}" value="${esc(S.stemUrl)}"></label>
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
<label>Stream format<select id="fmt">${[["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]
.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
<div class="row"><button class="btn" id="ssave">Save</button></div>
</div>
<h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · Q queue</p>`;
$("#signin").onclick = async () => {
const st = $("#sstatus");
try { S.plex = await plexSignIn(m => { st.textContent = m; }); S.section = null; save(); route(); }
catch (e) { st.textContent = e.message; }
};
if ($("#signout")) $("#signout").onclick = () => { S.plex = null; S.section = null; save(); route(); };
if ($("#sec")) $("#sec").onchange = ev => { S.section = ev.target.value; save(); };
$("#psave").onclick = () => {
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
S.plex = url && token ? { url, token, name: "" } : null; S.section = null; save(); route();
};
$("#ssave").onclick = async () => {
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); S.fmt = $("#fmt").value;
Stems.etag = null; save(); await Stems.refresh(); route();
};
},
};
async function route() {
const seq = ++routeSeq;
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
const arg = decodeURIComponent(rest.join("/"));
document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name));
lists = {};
view.scrollTop = 0;
try { await (routes[name] || routes.albums)(seq, arg); }
catch (e) { if (seq === routeSeq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
if (seq === routeSeq) markPlaying();
}
addEventListener("hashchange", route);
$("#search").onsubmit = ev => {
ev.preventDefault();
const q = ev.target.q.value.trim();
if (q) location.hash = "#/search/" + encodeURIComponent(q);
};
// Plex from the server's PLEX_URL / PLEX_TOKEN when nothing was signed in by hand, and a
// local stemserve when this page's own server has none.
async function autoConnect() {
const ok = await Stems.refresh();
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
try {
const r = await fetch(u + "/api/info" + stemQuery(), { signal: AbortSignal.timeout(1500) });
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
} catch {}
}
}
if (!S.plex || S.plex.env) {
try {
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null);
if (c?.plex) S.plex = { token: c.plex.token, url: stemBase() + "/plex", name: "", env: true };
else if (S.plex?.env) S.plex = null;
save();
} catch {}
}
}
// ---- start -----------------------------------------------------------------------------
(async () => {
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
const saved = load(KEY + "-queue");
if (saved?.items?.length) {
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
uidSeq = Math.max(uidSeq, ...saved.items.map(x => x.uid + 1));
if (Q.orig) Q.orig = Q.orig.map(o => Q.items.find(x => x.uid === o.uid) || o);
Q.start(saved.pos || 0, false);
}
UI.all();
route();
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
})();
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0e12">
<title>Stemify</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<aside id="nav">
<div class="brand">stemify</div>
<a href="#/albums" data-nav="albums">Albums</a>
<a href="#/artists" data-nav="artists">Artists</a>
<a href="#/playlists" data-nav="playlists">Playlists</a>
<a href="#/stems" data-nav="stems">Stems only</a>
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
<a href="#/settings" data-nav="settings" class="settings-link">Settings</a>
</aside>
<main id="view"></main>
<aside id="drawer" hidden></aside>
</div>
<footer id="player" class="empty">
<div class="np">
<img id="np-art" alt="">
<div class="np-text"><div id="np-title"></div><div id="np-sub"></div></div>
</div>
<div class="center">
<div class="transport">
<button id="b-shuffle" class="side" title="Shuffle (S)"></button>
<button id="b-prev" title="Previous"></button>
<button id="b-play" class="play" title="Play (Space)"></button>
<button id="b-next" title="Next"></button>
<button id="b-repeat" class="side" title="Repeat (R)"></button>
</div>
<div class="seek">
<span id="t-pos">0:00</span>
<canvas id="wave"></canvas>
<span id="t-dur">0:00</span>
</div>
</div>
<div class="right">
<select id="speed" title="Speed"></select>
<button id="b-mixer" class="pill" title="Mixer (M)"></button>
<button id="b-queue" class="pill" title="Queue (Q)"></button>
<input id="volume" type="range" min="0" max="1" step="0.01" title="Volume">
</div>
</footer>
<script type="module" src="app.js"></script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
:root {
--bg: #0d0e12;
--panel: #15171d;
--raised: #1d2028;
--line: #262a33;
--text: #eceef3;
--muted: #8a8f9c;
--accent: #f2b544;
--accent-ink: #1a1306;
--radius: 12px;
--player-h: 96px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; }
button, input, select { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; padding: 0; }
a { color: inherit; text-decoration: none; }
svg { width: 1em; height: 1em; fill: currentColor; display: block; }
#app { display: grid; grid-template-columns: 210px 1fr auto; height: calc(100% - var(--player-h)); }
#nav { background: var(--panel); border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 2px; }
.brand { white-space: nowrap; font-weight: 800; font-size: 20px; letter-spacing: -0.02em; padding: 0 10px 18px; }
.brand::after { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-left: 3px; }
#nav a { padding: 8px 10px; border-radius: 8px; color: var(--muted); font-weight: 500; }
#nav a:hover { color: var(--text); }
#nav a.on { background: var(--raised); color: var(--text); }
#search input { width: 100%; margin: 12px 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; outline: none; }
#search input:focus { border-color: var(--accent); }
.settings-link { margin-top: auto; }
#view { overflow-y: auto; padding: 28px 32px 48px; }
h1 { font-size: 28px; letter-spacing: -0.02em; margin: 0 0 4px; }
h2 { font-size: 17px; margin: 28px 0 12px; }
.sub { color: var(--muted); }
.bar { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; flex-wrap: wrap; }
.bar h1 { margin-right: auto; }
select, input[type=text], input[type=url], input[type=password] { background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 18px; }
.card { display: block; min-width: 0; }
.card img, .card .ph { width: 100%; aspect-ratio: 1; border-radius: 10px; object-fit: cover; background: var(--raised); display: block; transition: transform .15s; }
.card:hover img { transform: translateY(-2px); }
.card.round img, .card.round .ph { border-radius: 50%; }
.card .t { margin-top: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card .s { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hero { display: flex; gap: 28px; align-items: flex-end; margin-bottom: 26px; }
.hero img { width: 210px; height: 210px; border-radius: var(--radius); object-fit: cover; background: var(--raised); box-shadow: 0 20px 50px #0008; }
.hero .meta { min-width: 0; }
.hero .kind { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.btn { background: var(--raised); border-radius: 999px; padding: 9px 18px; font-weight: 600; display: inline-flex; gap: 8px; align-items: center; }
.btn.primary { background: var(--accent); color: var(--accent-ink); }
.btn:hover { filter: brightness(1.12); }
.tracks { width: 100%; border-collapse: collapse; }
.tracks tr { border-radius: 8px; }
.tracks td { padding: 8px 10px; border-bottom: 1px solid #ffffff08; white-space: nowrap; }
.tracks tr:hover td { background: var(--panel); }
.tracks tr.playing td { color: var(--accent); }
.tracks .n { width: 36px; color: var(--muted); text-align: right; }
.tracks .ti { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tracks .ti small { color: var(--muted); margin-left: 8px; }
.tracks .d { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.tracks .add { opacity: 0; color: var(--muted); font-size: 18px; }
.tracks tr:hover .add { opacity: 1; }
.tracks .add:hover { color: var(--text); }
.stem-dot { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--accent); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; margin-left: 8px; vertical-align: 1px; }
.more { display: block; margin: 24px auto; }
.empty-msg { color: var(--muted); padding: 40px 0; }
.form { max-width: 520px; display: grid; gap: 14px; }
.form label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
.form label input, .form label select { color: var(--text); font-size: 14px; }
.form .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.note { color: var(--muted); font-size: 13px; }
#drawer { width: 340px; background: var(--panel); border-left: 1px solid var(--line); overflow-y: auto; padding: 22px 20px; }
#drawer h2 { margin: 0 0 14px; }
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.chip { background: var(--raised); border-radius: 999px; padding: 5px 12px; font-size: 13px; font-weight: 500; }
.chip.on { background: var(--accent); color: var(--accent-ink); }
.chip:disabled { opacity: .35; cursor: default; }
.stem { display: grid; grid-template-columns: 1fr auto auto; gap: 8px 6px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); }
.stem .name { text-transform: capitalize; font-weight: 600; }
.stem.silent .name { color: var(--muted); }
.stem input { grid-column: 1 / -1; width: 100%; }
.ms { width: 30px; height: 26px; border-radius: 6px; background: var(--raised); font-size: 12px; font-weight: 700; color: var(--muted); }
.ms.on { background: var(--text); color: var(--bg); }
.ms.solo.on { background: var(--accent); color: var(--accent-ink); }
.q-item { display: flex; gap: 10px; align-items: center; padding: 7px 6px; border-radius: 8px; cursor: pointer; }
.q-item:hover { background: var(--raised); }
.q-item img { width: 38px; height: 38px; border-radius: 6px; object-fit: cover; background: var(--raised); flex: none; }
.q-item .tx { min-width: 0; flex: 1; }
.q-item .tx div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q-item .tx div + div { color: var(--muted); font-size: 12px; }
.q-item.cur { background: var(--raised); }
.q-item.cur .tx div:first-child { color: var(--accent); }
.q-item.past { opacity: .45; }
.q-item .x { color: var(--muted); font-size: 18px; padding: 0 4px; opacity: 0; }
.q-item:hover .x { opacity: 1; }
#player { position: fixed; left: 0; right: 0; bottom: 0; height: var(--player-h); background: #101116ee; backdrop-filter: blur(20px);
border-top: 1px solid var(--line); display: grid; grid-template-columns: minmax(180px, 1fr) minmax(320px, 1.6fr) minmax(180px, 1fr); align-items: center; gap: 20px; padding: 0 20px; }
#player.empty .center, #player.empty .right { opacity: .35; pointer-events: none; }
.np { display: flex; gap: 12px; align-items: center; min-width: 0; }
#np-art { width: 58px; height: 58px; border-radius: 8px; object-fit: cover; background: var(--raised); flex: none; }
#np-art:not([src]) { display: none; }
.np-text { min-width: 0; }
#np-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#np-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.center { display: grid; gap: 4px; }
.transport { display: flex; justify-content: center; align-items: center; gap: 18px; font-size: 20px; }
.transport button { color: var(--text); }
.transport .side { color: var(--muted); font-size: 17px; position: relative; }
.transport .side.on { color: var(--accent); }
.transport .side.on::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; }
.transport .play { width: 40px; height: 40px; border-radius: 50%; background: var(--text); color: var(--bg); display: grid; place-items: center; font-size: 18px; }
.transport .play.busy svg { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.seek { display: grid; grid-template-columns: 42px 1fr 42px; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.seek span:last-child { text-align: right; }
#wave { width: 100%; height: 32px; cursor: pointer; display: block; }
.right { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
.pill { color: var(--muted); font-size: 18px; width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; }
.pill.on { background: var(--raised); color: var(--accent); }
#speed { padding: 5px 6px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
#speed.on { color: var(--accent); border-color: var(--accent); }
#volume { width: 96px; }
input[type=range] { accent-color: var(--accent); }
@media (max-width: 860px) {
:root { --player-h: 150px; }
#app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
#nav { flex-direction: row; overflow-x: auto; padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--line); align-items: center; }
.brand { padding: 0 8px 0 4px; }
#nav a { white-space: nowrap; }
#search input { margin: 0; width: 120px; }
.settings-link { margin-top: 0; }
#view { padding: 20px 16px 40px; }
#drawer { position: fixed; inset: auto 0 var(--player-h) 0; width: auto; max-height: 60vh; border-left: 0; border-top: 1px solid var(--line); z-index: 5; }
#player { grid-template-columns: 1fr auto; grid-template-rows: auto auto; gap: 8px 12px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
.center { grid-column: 1 / -1; grid-row: 2; }
.right #volume { display: none; }
.hero { flex-direction: column; align-items: flex-start; }
.hero img { width: 160px; height: 160px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 16px 12px; }
}