Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0dba777f9 | ||
|
|
5b767cacc2 | ||
|
|
bba92967ea | ||
|
|
068f2309b4 | ||
|
|
eb541b9a16 | ||
|
|
790f8a35a2 | ||
|
|
e2cf62cb07 | ||
|
|
6f634522e5 | ||
|
|
d52a3e257f | ||
|
|
e98d595365 | ||
|
|
41b8171df2 | ||
|
|
8e5ebb70b1 |
No files matched your search
@@ -2,117 +2,66 @@
|
||||
|
||||
# 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
|
||||
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 runs on iPhone (with CarPlay), Android (with Android Auto) and Linux desktop. A GPU on your server does the separating in the background.
|
||||
|
||||
It's a home-grown, slightly over-engineered love letter to karaoke, music nerdery and the joy of
|
||||
pressing the "mute vocals" button at exactly the wrong moment.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [The big picture](#the-big-picture)
|
||||
- [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)
|
||||
- [How it works](#how-it-works)
|
||||
- [Apps](#apps)
|
||||
- [Setup](#setup)
|
||||
- [iOS](#ios)
|
||||
- [Android](#android)
|
||||
- [Desktop](#desktop)
|
||||
- [Stem Now](#stem-now)
|
||||
- [Reference](#reference)
|
||||
- [Building and testing](#building-and-testing)
|
||||
- [Repo tour](#repo-tour)
|
||||
- [Troubleshooting and FAQ](#troubleshooting-and-faq)
|
||||
- [Credits](#credits)
|
||||
- [License](#license)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Credits and license](#credits)
|
||||
|
||||
---
|
||||
## How it works
|
||||
|
||||
## The big picture
|
||||
|
||||
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.
|
||||
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/`.
|
||||
|
||||
```
|
||||
┌──────────────────────────── your server (unraid, in our case) ───────────────────────────┐
|
||||
│ │
|
||||
│ Music/ ──read──▶ stemd (GPU) ──writes──▶ Stems/ ──read──▶ stemserve (:8765) ─────┼──▶ phone, car, desktop
|
||||
│ ▲ ▲ │ │
|
||||
│ │ └──────── "Stem Now" requests ◀──────────────┘ │
|
||||
│ Plex Media Server (:32400) ──────────────────────────────────────────────────────────────┼──▶ library, artwork, playlists
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
your server
|
||||
Music/ ──▶ stemd (GPU) ──▶ Stems/ ──▶ stemserve :8765 ──▶ phone, car, desktop
|
||||
▲ │
|
||||
└──── Stem Now requests ◀───┘
|
||||
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,
|
||||
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** 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.
|
||||
|
||||
---
|
||||
## Apps
|
||||
|
||||
## What you get
|
||||
| | iOS | Android | Desktop player |
|
||||
|---|---|---|---|
|
||||
| Per-stem faders, mute, solo, mix presets | yes | yes | yes |
|
||||
| Waveform seek bar, live stem meters | 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 |
|
||||
| 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 phone (Android)**
|
||||
- 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.
|
||||
Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass.
|
||||
|
||||
**In your car (Android Auto)**
|
||||
- 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.
|
||||
## Setup
|
||||
|
||||
**On your desktop (Linux)**
|
||||
- **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.
|
||||
Set up the server first, then the apps. The examples use Unraid paths and `192.168.86.2` for the server; use your own.
|
||||
|
||||
**On the server**
|
||||
- **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.
|
||||
### Server
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## Setting it up
|
||||
|
||||
The recommended order is server first, then phone, then (if you like) desktop. The examples below
|
||||
use unraid paths and the addresses from the original setup (`192.168.86.2`). Swap in your own.
|
||||
|
||||
### 1. The server: stemd and stemserve
|
||||
|
||||
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):
|
||||
Make the folders, owned by `99:100` (Unraid's nobody:users):
|
||||
|
||||
```sh
|
||||
mkdir -p /mnt/user/appdata/stemserve/cache \
|
||||
@@ -121,8 +70,7 @@ mkdir -p /mnt/user/appdata/stemserve/cache \
|
||||
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
|
||||
at least `stemify/`, `server/` and `.dockerignore`) to `/mnt/user/appdata/stemd/src`, then:
|
||||
**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:
|
||||
|
||||
```sh
|
||||
docker build -f /mnt/user/appdata/stemd/src/server/stemd.Dockerfile -t stemd /mnt/user/appdata/stemd/src
|
||||
@@ -133,21 +81,11 @@ docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \
|
||||
-v /mnt/user/appdata/stemd/models:/models \
|
||||
-v /mnt/user/appdata/stemd/state:/state \
|
||||
stemd
|
||||
docker logs -f stemd
|
||||
```
|
||||
|
||||
The first song downloads the model (about 700 MB). After that the log reads like a very calm
|
||||
sports commentary: how many tracks are left, which one it's on, and how many times faster than
|
||||
realtime it went.
|
||||
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.
|
||||
|
||||
**stemserve** is tiny. Copy the `server/` folder to `/mnt/user/appdata/stemserve` and either use
|
||||
the included compose file:
|
||||
|
||||
```sh
|
||||
cd /mnt/user/appdata/stemserve && docker compose up -d --build
|
||||
```
|
||||
|
||||
or run it by hand:
|
||||
**stemserve** is one Python file using only the standard library. Copy `server/` to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` there, or by hand:
|
||||
|
||||
```sh
|
||||
docker build -t stemserve /mnt/user/appdata/stemserve
|
||||
@@ -158,433 +96,264 @@ docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary><b>Server settings (environment variables)</b></summary>
|
||||
<summary>Server environment variables</summary>
|
||||
|
||||
**stemd**
|
||||
|
||||
| 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 |
|
||||
| `STATE_DIR` | `/state` | The failed-track list lives here |
|
||||
| `QUEUE_DIR` | `STATE_DIR/queue` | Where "Stem Now" requests arrive |
|
||||
| `STATE_DIR` | `/state` | Holds the failed-track list |
|
||||
| `QUEUE_DIR` | `STATE_DIR/queue` | Where Stem Now requests arrive |
|
||||
| `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` |
|
||||
| `RESCAN_MIN` | `30` | Minutes between library scans once it's caught up |
|
||||
| `FP16` | `1` | Half precision on the GPU (`0` for full fp32) |
|
||||
| `OVERLAP`, `SEGMENT`, `SHIFTS` | `0.25`, `0`, `0` | Separation quality knobs, same as the desktop app |
|
||||
| `RESCAN_MIN` | `30` | Minutes between library scans once caught up |
|
||||
| `FP16` | `1` | Half precision on the GPU (`0` for fp32) |
|
||||
| `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**
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `STEMS_DIR` | `/stems` | The stems folder |
|
||||
| `CACHE_DIR` | `/cache` | Opus transcodes and waveforms (safe to delete any time) |
|
||||
| `QUEUE_DIR` | `/queue` | stemd's "Stem Now" folder, shared with it |
|
||||
| `CACHE_DIR` | `/cache` | Opus transcodes and waveforms (safe to delete) |
|
||||
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder, shared with it |
|
||||
| `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 |
|
||||
|
||||
</details>
|
||||
|
||||
**Updating later:** copy the new files over, rebuild the image, `docker rm -f` the container, and
|
||||
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.
|
||||
### Connecting an app
|
||||
|
||||
### 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.
|
||||
|
||||
The apps check stemserve for newly separated songs every minute, so the library fills in as stemd works.
|
||||
|
||||
## iOS
|
||||
|
||||
A SwiftUI app for iOS 17 or newer, with CarPlay and Siri ("play Paranoid in Stemify").
|
||||
|
||||
### Install
|
||||
|
||||
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:
|
||||
|
||||
1. Grab `Stemify.apk` from the Releases page, or [build it yourself](#building-and-testing),
|
||||
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
|
||||
up" as stemd works through it.
|
||||
|
||||
### 3. The desktop apps (optional)
|
||||
|
||||
Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). The default setup uses PyTorch for ROCm
|
||||
(AMD GPUs, developed on an RX 9070 XT). NVIDIA and CPU work too if you point the torch index
|
||||
elsewhere in `pyproject.toml`.
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
uv run stemify # the drag-and-drop separator
|
||||
uv run stemify-player # the Excel-shaped music player
|
||||
./scripts/install-desktop.sh # adds both to your app grid ("--uninstall" removes them)
|
||||
https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json
|
||||
```
|
||||
|
||||
---
|
||||
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.
|
||||
- **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
|
||||
**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.
|
||||
## Android
|
||||
|
||||
**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**
|
||||
- **Download** (or **Remove Download**)
|
||||
- **Add to Playlist** (playlists can even go into other playlists)
|
||||
- **Stem Now**, while any of it lacks stems
|
||||
### Using it
|
||||
|
||||
**The Now Playing screen** has the waveform seek bar, the mixer (faders, mute, solo, mix presets,
|
||||
meters, master volume) and the queue. Your mix can carry over from song to song or reset each
|
||||
time, your choice.
|
||||
- **Tabs:** Library, Playlists, Search, Downloads and Settings.
|
||||
- **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.
|
||||
- **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
|
||||
without stems). Sort by download order, artist or release date.
|
||||
### Equalizer
|
||||
|
||||
**Stem Quality** decides how stems travel from the server: lossless FLAC, or Opus at a bitrate of
|
||||
your choosing, which takes roughly an eighth of the space and is transcoded and cached on the
|
||||
server.
|
||||
Settings → Equalizer has two parts.
|
||||
|
||||
**Streaming** needs your home network (Plex and stemserve are plain HTTP on the LAN). Anything
|
||||
you've downloaded or already streamed plays anywhere.
|
||||
- **Graphic EQ:** ten bands from 31 Hz to 16 kHz, with presets like Bass Booster, Late Night and Vocal Booster.
|
||||
- **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
|
||||
karaoke your way through an album tonight, hold it and choose **Stem Now**.
|
||||
### Android Auto
|
||||
|
||||
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.
|
||||
2. stemserve drops a little request file into stemd's queue folder.
|
||||
3. stemd finishes the song it's on, then does yours next, ahead of everything else. If it was
|
||||
idle, it starts within about 5 seconds.
|
||||
4. About a minute after each song finishes, the app notices and plays it with stems from then on.
|
||||
- Tabs for Playlists, Albums, Artists and Downloads (Downloads first when you're away from home).
|
||||
- Every song list starts with Shuffle all, and lists over 40 items split into A to Z folders.
|
||||
- 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".
|
||||
- Voice: "play Paranoid on Stemify" works for playlists, artists, albums and songs.
|
||||
- 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
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
To test without a car, start the head unit server from Auto's developer settings, then:
|
||||
|
||||
```sh
|
||||
adb forward tcp:5277 tcp:5277
|
||||
~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
|
||||
```
|
||||
|
||||
---
|
||||
## Desktop
|
||||
|
||||
## iOS and CarPlay
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
On top of the Android features: the Save chip in the mixer remembers a mix for that song and puts it back every time it plays, and the mic button turns on karaoke mode (vocals muted on every song, synced lyrics from [LRCLIB](https://lrclib.net) on Now Playing; tap a line to jump to it).
|
||||
|
||||
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
|
||||
|
||||
```
|
||||
cd ios
|
||||
scripts/build-ipa.sh # writes build/stemify-<version>.ipa
|
||||
```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 the IPA with Sideloadly or AltStore, which sign it with your Apple ID.
|
||||
**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.
|
||||
|
||||
To get updates in AltStore, SideStore or Feather, add this source:
|
||||
**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).
|
||||
|
||||
```
|
||||
https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json
|
||||
```
|
||||
## Stem Now
|
||||
|
||||
After uploading a new release (tag `ios-v<version>`, asset `stemify-<version>.ipa`), run `ios/scripts/make-source.py` to regenerate `ios/source.json` and commit it. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
|
||||
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.
|
||||
|
||||
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.
|
||||
1. The app sends the songs to stemserve, which drops a request file in stemd's queue folder.
|
||||
2. stemd finishes the song it's on and does yours next. If it was idle, it starts within about 5 seconds.
|
||||
3. About a minute after each song finishes, the app picks up its stems.
|
||||
|
||||
## The desktop apps
|
||||
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.
|
||||
|
||||
### Stemify (the separator)
|
||||
## Reference
|
||||
|
||||
Drop files onto the window. Each becomes a queue item, separation starts right away with your
|
||||
last-used model and device, and when it's done you get a lane per stem with a waveform, solo, mute,
|
||||
gain and export. Export everything, one stem, or your current mix, as WAV, FLAC or MP3.
|
||||
### Separation models
|
||||
|
||||
Other niceties:
|
||||
| Model id | Stems | Notes |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
- Separation runs in its own worker process, so the UI never stutters, even mid-separation.
|
||||
- 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)
|
||||
|
||||
The desktop sibling of the phone app, disguised as Microsoft Excel 2016. Some highlights from the
|
||||
translation guide:
|
||||
|
||||
| In Excel | In Stemify Player |
|
||||
|---|---|
|
||||
| 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:
|
||||
### Folder layout
|
||||
|
||||
```
|
||||
Music/Kansas/Leftoverture/01 Carry On Wayward Son.flac
|
||||
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 - 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
|
||||
last, so a separation interrupted by a reboot is simply redone next time. No half-baked songs.
|
||||
- When a folder holds the same song in several formats, the best one wins (FLAC/WAV, then M4A,
|
||||
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.
|
||||
- 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.
|
||||
- If a folder has the same song in several formats, the best one wins (FLAC/WAV, then M4A, Opus/Ogg, MP3, WMA).
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## stemserve API
|
||||
### stemserve API
|
||||
|
||||
| Request | Returns |
|
||||
|---|---|
|
||||
| `GET /api/info` | Track count, whether ffmpeg is available, whether Stem Now is set up |
|
||||
| `GET /api/index` | Every finished song (with ETag, so unchanged indexes cost one tiny 304) |
|
||||
| `GET /api/info` | Track count, ffmpeg availability, whether Stem Now is set up |
|
||||
| `GET /api/index` | Every finished song (with ETag) |
|
||||
| `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/stem` with `{"paths": [...]}` | Stem Now: stemd separates these Plex files next |
|
||||
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem itself, with HTTP range support for seeking |
|
||||
| `POST /api/stem` with `{"paths": [...]}` | Stem Now for these Plex files |
|
||||
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem, with range support |
|
||||
|
||||
`K` is the song's folder under the stems root, e.g. `Kansas/Leftoverture/01 Carry On Wayward Son`.
|
||||
Want to run it outside Docker? It's one file:
|
||||
`K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker:
|
||||
|
||||
```sh
|
||||
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
|
||||
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
|
||||
|
||||
**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):
|
||||
|
||||
```sh
|
||||
cd android
|
||||
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
|
||||
themselves when there isn't one. Debug builds include a hook for screenshots and back-gesture
|
||||
testing (`app/src/debug`); release builds don't.
|
||||
The cache and index tests use a stemserve on `localhost:8765` and skip themselves without one.
|
||||
|
||||
**Desktop** apps can be checked without opening a window:
|
||||
**Desktop** can be checked without a window:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
**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.
|
||||
|
||||
---
|
||||
**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.**
|
||||
Hold it and choose **Stem Now**. It'll be next in line.
|
||||
**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.
|
||||
|
||||
**Stem Now says "stemserve needs updating" or "queue folder isn't mounted".**
|
||||
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.
|
||||
**Stemify isn't in CarPlay or Siri.** The IPA was signed without those entitlements. See [iOS install](#install).
|
||||
|
||||
**stemd says it's separating on the CPU.**
|
||||
The container can't see the GPU. Make sure it's running with `--runtime=nvidia` and the NVIDIA
|
||||
environment variables shown above.
|
||||
**Stemify isn't in Android Auto.** Turn on Unknown sources in Auto's developer settings. See [Android Auto](#android-auto).
|
||||
|
||||
**stemd keeps saying "out of GPU memory".**
|
||||
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.
|
||||
|
||||
---
|
||||
**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.
|
||||
|
||||
## Credits
|
||||
|
||||
Standing on the shoulders of some very clever people:
|
||||
|
||||
- [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,
|
||||
and Kim's vocal model fine-tuned by unwa, via
|
||||
[python-audio-separator](https://github.com/nomadkaraoke/python-audio-separator)
|
||||
- [PyTorch](https://pytorch.org/), [PySide6 / Qt](https://www.qt.io/qt-for-python),
|
||||
[Jetpack Compose](https://developer.android.com/compose) and
|
||||
[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
|
||||
- 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)
|
||||
- [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)
|
||||
- [Equalizer APO](https://sourceforge.net/projects/equalizerapo/) and [AutoEQ](https://github.com/jaakkopasanen/AutoEq), whose formats the EQ reads and writes
|
||||
- [LRCLIB](https://lrclib.net) for synced lyrics
|
||||
- Inter, Selawik and Carlito fonts (licenses included alongside them)
|
||||
|
||||
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
|
||||
|
||||
[GPL-3.0](LICENSE). Take it apart and put it back together however you like, just like the songs.
|
||||
[GPL-3.0](LICENSE)
|
||||
@@ -20,6 +20,7 @@ final class AppGraph {
|
||||
let cache: MediaCache
|
||||
let eq: EqController
|
||||
let playback: PlaybackManager
|
||||
let stemPlaylists: StemPlaylists
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
@@ -59,6 +60,7 @@ final class AppGraph {
|
||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||
|
||||
eq = EqController(dir: support)
|
||||
stemPlaylists = StemPlaylists(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)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
|
||||
@@ -185,7 +185,14 @@ final class Settings: @unchecked Sendable {
|
||||
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.
|
||||
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 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 {
|
||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||
set { write(\.masterVolume, "masterVolume", newValue, 0.85) }
|
||||
|
||||
@@ -104,6 +104,7 @@ private final class OutputTap: @unchecked Sendable {
|
||||
private var lastNanos: Int64 = 0
|
||||
private var runningFlag = false
|
||||
private var latencyNanos: Int64 = 0
|
||||
private var speedFactor = 1.0
|
||||
private let scratch: UnsafeMutablePointer<Float>
|
||||
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) } }
|
||||
|
||||
/// 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() {
|
||||
lock.with {
|
||||
ring.clear()
|
||||
@@ -135,7 +142,7 @@ private final class OutputTap: @unchecked Sendable {
|
||||
lock.with {
|
||||
if lastNanos == 0 { return 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)
|
||||
}
|
||||
}
|
||||
@@ -203,9 +210,10 @@ final class StemEngine: @unchecked Sendable {
|
||||
var rate: Int
|
||||
var nanos: Int64
|
||||
var running: Bool
|
||||
var speed = 1.0
|
||||
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +231,8 @@ final class StemEngine: @unchecked Sendable {
|
||||
private let av = AVAudioEngine()
|
||||
private var node: AVAudioSourceNode?
|
||||
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 written: Int64 = 0
|
||||
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) {
|
||||
post { [self] in
|
||||
guard let s = session else { return }
|
||||
@@ -601,12 +622,15 @@ final class StemEngine: @unchecked Sendable {
|
||||
#endif
|
||||
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
||||
t.setLatency(Self.outputLatency())
|
||||
t.speed = speed
|
||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
||||
throw DecodeError("Unsupported sample rate \(rate)")
|
||||
}
|
||||
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
|
||||
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()
|
||||
node = n
|
||||
tap = t
|
||||
@@ -655,9 +679,9 @@ final class StemEngine: @unchecked Sendable {
|
||||
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
|
||||
if let seg = segments.first {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ final class NowPlaying: @unchecked Sendable {
|
||||
MPMediaItemPropertyAlbumTitle: t.album,
|
||||
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 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,
|
||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
|
||||
|
||||
@@ -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.
|
||||
struct StemSetting: Codable, Hashable {
|
||||
static let unity: Float = 0.8
|
||||
@@ -44,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
|
||||
struct QueueItem: Codable, Hashable, Identifiable {
|
||||
var uid: Int64
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -119,12 +123,13 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
private(set) var stemNames: [String] = []
|
||||
private(set) var mix: [String: StemSetting] = [:]
|
||||
private(set) var master: Float
|
||||
private(set) var speed = 1.0
|
||||
private(set) var waveform: Waveform?
|
||||
private(set) var durationMs: Int64 = 0
|
||||
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 out on every song, lyrics on Now Playing.
|
||||
/// 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]?
|
||||
|
||||
@@ -161,6 +166,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||
engine.eqParams = eq.currentParams()
|
||||
engine.masterGain = masterTaper(master)
|
||||
speed = settings.playbackSpeed
|
||||
engine.setSpeed(speed)
|
||||
restore()
|
||||
observeSession()
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -202,16 +209,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
|
||||
// ---- queue --------------------------------------------------------------------------
|
||||
|
||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
||||
tracks.map { t in
|
||||
private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
|
||||
tracks.enumerated().map { i, t in
|
||||
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 }
|
||||
let items = items(tracks)
|
||||
let items = items(tracks, mixes)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
@@ -226,12 +234,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
||||
func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
|
||||
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 }
|
||||
let items = items(tracks)
|
||||
let items = items(tracks, mixes)
|
||||
var q = queue
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
@@ -324,6 +332,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
|
||||
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 ----------------------------------------------------------------------
|
||||
|
||||
func play() {
|
||||
@@ -413,13 +430,33 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
||||
private func startMix(_ t: Track) -> [String: StemSetting] {
|
||||
if karaoke { return Self.karaokeMix }
|
||||
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
/// 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 : [:])
|
||||
}
|
||||
|
||||
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
|
||||
/// 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) {
|
||||
@@ -427,9 +464,9 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
karaoke = on
|
||||
if on {
|
||||
preKaraokeMix = mix
|
||||
applyMix(Self.karaokeMix)
|
||||
applyMix(karaokeMix)
|
||||
} else {
|
||||
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
preKaraokeMix = nil
|
||||
changed()
|
||||
}
|
||||
@@ -506,7 +543,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
if play && !activateSession() { return }
|
||||
playWhenReady = play
|
||||
buffering = true
|
||||
mix = startMix(item.track)
|
||||
mix = startMix(item)
|
||||
changed()
|
||||
save()
|
||||
loadTask = Task { @MainActor [weak self] in
|
||||
@@ -679,7 +716,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
guard let item = current else { return }
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
mix = startMix(item.track)
|
||||
mix = startMix(item)
|
||||
source = currentEntry != nil ? .Stems : .Original
|
||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||
pushMix()
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,7 @@ extension PlexItem {
|
||||
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,49 @@ struct MediaMenu {
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
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)
|
||||
}
|
||||
|
||||
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).
|
||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||
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) }
|
||||
})
|
||||
}
|
||||
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)) }
|
||||
return a + extra
|
||||
}
|
||||
@@ -132,32 +171,57 @@ struct MediaMenu {
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
||||
Task {
|
||||
let lists: [Playlist]
|
||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
/// Plex or stem playlist, picked in one sheet.
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") {
|
||||
overlays.addToPlaylist(tracks, title: title, skip: skip)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
||||
if keys.isEmpty {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
||||
return
|
||||
chooseMix(tracks) { m in
|
||||
onCreated(lists.create(name, tracks: tracks, mix: m))
|
||||
overlays.hud("Created “\(name)”")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a stem playlist from a Plex one, every song with the same mix.
|
||||
func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||
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 {
|
||||
do {
|
||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
overlays.hud("Added to \(pl.title)")
|
||||
} catch {
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
let lists = g.stemPlaylists
|
||||
overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
|
||||
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))")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||
|
||||
@@ -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 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 AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
var addToPlaylist: AddToPlaylist?
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
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) }
|
||||
}
|
||||
|
||||
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") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
@@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
|
||||
@@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier {
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.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> {
|
||||
|
||||
@@ -18,7 +18,7 @@ enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
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.
|
||||
|
||||
@@ -14,15 +14,12 @@ struct RootView: View {
|
||||
root(t).miniPlayerRoom()
|
||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||
}
|
||||
// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
||||
.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)
|
||||
.modifier(FloatingMiniPlayer())
|
||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||
.tag(t)
|
||||
}
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
@@ -68,6 +65,39 @@ struct RootView: View {
|
||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||
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 }
|
||||
func body(content: Content) -> some View {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ struct NowPlayingScreen: View {
|
||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||
.padding(.top, panel == .Art ? 34 : 18)
|
||||
HStack {
|
||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
Spacer()
|
||||
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
||||
Spacer()
|
||||
@@ -169,10 +170,12 @@ struct NowPlayingScreen: View {
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
Spacer()
|
||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
}
|
||||
.frame(height: 84)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
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) }
|
||||
@@ -183,7 +186,7 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
|
||||
/// Opens a panel, or goes back to the art if it's already open.
|
||||
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||
/// 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)) {
|
||||
@@ -246,6 +249,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 {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
@@ -794,8 +842,14 @@ struct MiniPlayer: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
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
|
||||
if let t = pm.current?.track {
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
@@ -803,19 +857,7 @@ struct MiniPlayer: View {
|
||||
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())
|
||||
MiniTransport(color: p.label, next: true)
|
||||
}
|
||||
.padding(.leading, 8).padding(.trailing, 6)
|
||||
.frame(height: 60)
|
||||
@@ -846,6 +888,56 @@ struct MiniPlayer: View {
|
||||
.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.
|
||||
@@ -858,6 +950,31 @@ private struct LyricsPanel: View {
|
||||
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) }
|
||||
}
|
||||
.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()
|
||||
@@ -869,7 +986,9 @@ private struct LyricsPanel: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: track.ratingKey) {
|
||||
}
|
||||
|
||||
private func follow() async {
|
||||
loading = true
|
||||
at = -1
|
||||
lyrics = await LyricsSource.shared.lyrics(track)
|
||||
@@ -883,7 +1002,6 @@ private struct LyricsPanel: View {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lines(_ l: Lyrics) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
|
||||
@@ -2,15 +2,25 @@ import SwiftUI
|
||||
|
||||
// ---- playlists ------------------------------------------------------------------------------
|
||||
|
||||
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
|
||||
|
||||
struct PlaylistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var lists: LoadState<[Playlist]> = .loading
|
||||
@State private var reload = 0
|
||||
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
|
||||
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Playlists") {
|
||||
Picker("Kind", selection: $kind) {
|
||||
Text("Plex").tag(PlaylistKind.Plex)
|
||||
Text("Stem").tag(PlaylistKind.Stem)
|
||||
}
|
||||
.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",
|
||||
@@ -20,11 +30,21 @@ struct PlaylistsScreen: View {
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
@@ -45,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") }
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
private struct ActionCell: View {
|
||||
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: "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)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.5"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
MARKETING_VERSION: "1.0.10"
|
||||
CURRENT_PROJECT_VERSION: "11"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
|
||||
@@ -15,6 +15,51 @@
|
||||
"tintColor": "#FA2D48",
|
||||
"category": "entertainment",
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
|
||||
Reference in new issue
Block a user