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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:30:02 -07:00
cdnutterandClaude Opus 5.5 605b88f1eb ios: source.json for 1.0.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:21:34 -07:00
16 changed files with 1477 additions and 72 deletions

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ ios/build/
ios/Stemify.xcodeproj/
ios/Stemify/Info.plist
ios/Stemify/Stemify.entitlements
ios/prov
+41 -15
View File
@@ -30,6 +30,12 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu
Plex Media Server :32400 ───────────────────────────────▶ library, artwork, playlists
```
Three parts, and only stemd is required:
- **stemd** is the daemon. It does the actual separating and nothing else.
- **Stemify web UI** is optional, for desktop users. stemserve serves it at `/`. It plays the stems streamed from the server, and picks up a local stem server on `localhost:8765` by itself.
- **iOS and Android apps** are optional and built from this repo too.
- **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.
@@ -37,19 +43,21 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu
## Apps
| | 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 | |
| | iOS | Android | Desktop player | Web |
|---|---|---|---|---|
| Per-stem faders, mute, solo, mix presets | yes | yes | yes | yes |
| Waveform seek bar, live stem meters | yes | yes | | waveform |
| Shuffle, repeat all/one | yes | yes | | yes |
| Playback speed (keeps pitch) | yes | | | yes |
| Saved mix per song | yes | | | |
| Karaoke mode with synced lyrics | yes | | | |
| Stem playlists (a mix per song) | yes | | | |
| Plex playlists | yes | yes | yes | yes |
| Downloads for offline | yes | yes | | |
| 10-band and parametric EQ, AutoEQ import | yes | yes | | |
| Car | CarPlay | Android Auto | | |
| Voice | Siri | Google Assistant | | |
| Stem Now | yes | yes | | |
Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass.
@@ -85,10 +93,10 @@ docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \
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 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:
**stemserve** is one Python file using only the standard library. It also serves the web player from `web/`. Copy the repo's `server/` and `web/` folders to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` in `server/`, or by hand:
```sh
docker build -t stemserve /mnt/user/appdata/stemserve
docker build -f /mnt/user/appdata/stemserve/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
-v /mnt/user/Music/Stems:/stems:ro \
-v /mnt/user/appdata/stemserve/cache:/cache \
@@ -130,6 +138,9 @@ To update, copy the new files, rebuild, `docker rm -f` the container and run it
| `PORT` | `8765` | Listen port |
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `PLEX_URL` | empty | Plex address. With `PLEX_TOKEN`, the web UI connects to Plex without signing in |
| `PLEX_TOKEN` | empty | Plex token. Only handed out to clients that pass `TOKEN`, so set `TOKEN` too |
| `WEB` | `1` | Set to `0` to turn off the web UI and serve the API only |
</details>
@@ -158,6 +169,7 @@ CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free
### Using it
- **Now Playing:** shuffle and repeat sit either side of the play controls; tap repeat again for repeat one. The speed button (next to the mixer) goes from 0.5× to 2× without changing pitch.
- **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
- **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet.
- **Playlists:** the Playlists tab switches between Plex and Stem.
@@ -211,6 +223,18 @@ adb forward tcp:5277 tcp:5277
~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
```
## Web
stemserve also serves a web player at its root, so `http://<server>:8765/` opens it. Sign in with Plex in Settings (or paste a server URL and token), then browse albums, artists, playlists and search like the apps. **Stems only** lists everything in the stems folder and works without Plex.
- Mixer, presets, shuffle, repeat, speed, queue and the waveform seek bar work like the phone apps. The mix carries over to the next song.
- Songs without stems play straight from Plex.
- Keys: Space play/pause, arrows seek, S shuffle, R repeat, `[` `]` speed, M mixer, Q queue.
- Settings, queue and mix are kept in the browser. Nothing syncs with the apps.
- If stemserve has `TOKEN` set, enter it in Settings.
It's plain HTML and JavaScript in `web/` with no build step. To run it somewhere else, serve `web/` and proxy `/api/` and `/file/` to stemserve on the same origin; with a different origin the stem faders fall back to element volume, which iOS Safari ignores.
## Desktop
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.
@@ -274,6 +298,7 @@ Stems/Kansas/Leftoverture/01 Carry On Wayward Son/
| `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background |
| `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 |
| `GET /` | The web player from `web/` (or `WEB_DIR`), no token needed |
`K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker:
@@ -289,6 +314,7 @@ ios/ iOS and CarPlay app (SwiftUI, xcodegen project)
scripts/ build-ipa.sh, make-source.py
android/ Android and Android Auto app (Kotlin, Compose, Media3)
server/ stemd.py, stemserve.py, Dockerfiles, compose file
web/ web player (served by stemserve)
stemify/ desktop separator (PySide6 + QML) and shared separation code
worker/ Demucs and RoFormer backends, run in a separate process
player/ Stemify Player
+84
View File
@@ -0,0 +1,84 @@
import Foundation
import SwiftUI
import UniformTypeIdentifiers
/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings.
/// Plex and stem server tokens and URLs are never included.
struct StemifyExport: Codable {
static let format = "stemify-export"
static let version = 1
struct Prefs: Codable {
var stemQuality: String?
var keepStemMix: Bool?
var karaokeVocals: Int?
var karaokeSoftDb: Int?
var playbackSpeed: Double?
var downloadsWifiOnly: Bool?
}
var format = StemifyExport.format
var version = StemifyExport.version
var exported = Date()
var playlists: [StemPlaylist]
var prefs: Prefs
@MainActor
static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport {
StemifyExport(playlists: lists.lists, prefs: Prefs(
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly))
}
func encoded() throws -> Data {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.dateEncodingStrategy = .iso8601
return try e.encode(self)
}
static func decode(_ data: Data) throws -> StemifyExport {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
let x = try d.decode(StemifyExport.self, from: data)
guard x.format == Self.format else { throw BackupError.notAnExport }
guard x.version <= Self.version else { throw BackupError.newerVersion }
return x
}
@MainActor
func apply(to s: Settings) {
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
if let v = prefs.keepStemMix { s.keepStemMix = v }
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
}
}
enum BackupError: LocalizedError {
case notAnExport, newerVersion
var errorDescription: String? {
switch self {
case .notAnExport: "That file isn't a Stemify export."
case .newerVersion: "That export is from a newer version of Stemify."
}
}
}
/// Where an export goes and comes from. The Files sheet covers local storage and iCloud Drive;
/// a direct iCloud (ubiquity container / CloudKit) backend would be another conformer.
protocol BackupDestination {
func write(_ data: Data, name: String) async throws
func read() async throws -> Data
}
/// The export as a document for fileExporter, which also reaches iCloud Drive through Files.
struct ExportDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
var data: Data
init(data: Data = Data()) { self.data = data }
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
}
+18
View File
@@ -70,6 +70,24 @@ final class StemPlaylists {
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
}
/// Brings in playlists from an export: new ids are added, ids already here are replaced when the
/// import was updated more recently. Returns how many lists were added or replaced.
@discardableResult
func merge(_ incoming: [StemPlaylist]) -> Int {
var n = 0
for pl in incoming {
if let i = lists.firstIndex(where: { $0.id == pl.id }) {
guard pl.updated > lists[i].updated else { continue }
lists[i] = pl
} else {
lists.append(pl)
}
n += 1
}
if n > 0 { save() }
return n
}
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
change(&lists[i])
+85 -47
View File
@@ -98,21 +98,32 @@ struct NowPlayingScreen: View {
let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base
let track = pm.current?.track
VStack(spacing: 0) {
// Wide when there's no room to stack art over controls: landscape, or iPhone Duo's
// outer display. Tall layouts (including the unfolded Duo) cap the controls' width.
GeometryReader { geo in
let wide = geo.size.width > geo.size.height * 0.9
if let track {
Group {
switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
if wide {
HStack(spacing: 0) {
panelView(track, header: false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track, wide: true)
.frame(width: min(geo.size.width * 0.48, 440))
.frame(maxHeight: .infinity)
}
} else {
VStack(spacing: 0) {
panelView(track, header: true)
.frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track, wide: false).frame(maxWidth: 560)
}
.frame(maxWidth: .infinity)
}
.frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track)
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(.top, 20)
@@ -131,11 +142,21 @@ struct NowPlayingScreen: View {
}
}
private func controls(_ track: Track) -> some View {
@ViewBuilder
private func panelView(_ track: Track, header: Bool) -> some View {
switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
}
}
private func controls(_ track: Track, wide: Bool) -> some View {
let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base
return VStack(spacing: 0) {
if panel == .Art {
if wide || panel == .Art {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 1) {
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
@@ -151,7 +172,7 @@ struct NowPlayingScreen: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, panel == .Art ? 34 : 18)
.padding(.top, wide ? 24 : panel == .Art ? 34 : 18)
HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer()
@@ -173,6 +194,7 @@ struct NowPlayingScreen: View {
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
}
.frame(height: 84)
.padding(.top, 14)
HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
SpeedMenu()
@@ -603,18 +625,11 @@ private struct MixerPanel: View {
}
}
}
p.separator.frame(height: 0.5).padding(.vertical, 10)
HStack {
Text("Master").font(.headline).foregroundStyle(p.label)
Spacer()
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
}
HStack(spacing: 8) {
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
p.separator.frame(height: 0.5).padding(.vertical, 6)
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false) {
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
}
Spacer(minLength: 16)
} trailing: { EmptyView() }
Spacer(minLength: 12)
}
.padding(.horizontal, 24)
}
@@ -654,32 +669,55 @@ private struct StemFader: View {
var body: some View {
let color = Color(hex: Stems.color(name))
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
Spacer()
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted) {
VStack(spacing: 0) {
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
pm.setStemLevel(name, $0)
}
}
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
pm.setStemLevel(name, $0)
}
// Live level under the fader, -48..0 dBFS.
GeometryReader { geo in
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
ZStack(alignment: .leading) {
Capsule().fill(p.fill)
Capsule().fill(color).frame(width: geo.size.width * f)
// Live level under the fader, -48..0 dBFS.
GeometryReader { geo in
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
ZStack(alignment: .leading) {
Capsule().fill(p.fill)
Capsule().fill(color).frame(width: geo.size.width * f)
}
}
.frame(height: 3)
.padding(.horizontal, 12)
}
} trailing: {
if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
}
.frame(height: 3)
.padding(.horizontal, 12)
}
.padding(.top, 10)
}
}
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
private struct FaderRow<Fader: View, Trailing: View>: View {
let dot: Color?
let label: String
let percent: Int
let dim: Bool
@ViewBuilder let fader: Fader
@ViewBuilder let trailing: Trailing
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
Text(label).font(.subheadline.weight(.semibold)).foregroundStyle(dim ? p.label2 : p.label)
.lineLimit(1).minimumScaleFactor(0.8)
}
.frame(width: 70, alignment: .leading)
fader
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
.frame(width: 36, alignment: .trailing)
trailing
}
.frame(height: 46)
}
}
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
private struct NavCell: View {
@@ -147,6 +148,11 @@ struct SettingsScreen: View {
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
} header: { Header(text: "Storage") }
Section {
BackupCells()
} header: { Header(text: "Backup") } footer: {
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
}
Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
} header: { Header(text: "Display") } footer: {
@@ -159,6 +165,42 @@ struct SettingsScreen: View {
}
}
struct BackupCells: View {
private var g: AppGraph { .shared }
@State private var doc = ExportDocument()
@State private var exporting = false
@State private var importing = false
@State private var message: String?
@Environment(\.palette) private var p
var body: some View {
Button {
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true }
catch { message = error.localizedDescription }
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
.themedRow()
Button { importing = true } label: { ValueCell(title: "Import", value: nil, icon: "square.and.arrow.down", iconBg: 0x34C759) }
.themedRow()
.fileExporter(isPresented: $exporting, document: doc, contentType: .json, defaultFilename: "Stemify \(Date().formatted(.iso8601.year().month().day()))") { r in
if case .failure(let e) = r { message = e.localizedDescription }
}
.fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { r in
do {
let url = try r.get()
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let x = try StemifyExport.decode(Data(contentsOf: url))
let n = g.stemPlaylists.merge(x.playlists)
x.apply(to: g.settings)
message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings."
} catch { message = error.localizedDescription }
}
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(message ?? "") }
}
}
// ---- plex -----------------------------------------------------------------------------------
struct PlexAccountScreen: View {
+4 -4
View File
@@ -6,8 +6,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.10"
CURRENT_PROJECT_VERSION: "11"
MARKETING_VERSION: "1.0.13"
CURRENT_PROJECT_VERSION: "14"
targets:
Stemify:
type: application
@@ -32,7 +32,7 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen: {}
UIBackgroundModes: [audio, fetch]
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
CADisableMinimumFrameDurationOnPhone: true
ITSAppUsesNonExemptEncryption: false
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
@@ -44,7 +44,7 @@ targets:
INIntentsSupported: [INPlayMediaIntent]
INSupportedMediaCategories: [INMediaCategoryMusic]
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: true
UIApplicationSupportsMultipleScenes: false
UISceneConfigurations:
UIWindowSceneSessionRoleApplication:
- UISceneConfigurationName: Phone
+28 -1
View File
@@ -1,5 +1,7 @@
#!/bin/bash
# Builds an unsigned build/stemify-<version>.ipa on a Mac, for re-signing with Sideloadly/AltStore.
# Builds build/stemify-<version>.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore.
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed
# prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -34,5 +36,30 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
# Unsigned build, for the releases and AltStore/Sideloadly.
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
echo "$OUT/stemify-$VERSION.ipa"
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
security create-keychain -p "$KP" $KC
security set-keychain-settings -lut 3600 $KC
security unlock-keychain -p "$KP" $KC
security import prov/key.p12 -P "$P12_PASS" -A -t agg -f pkcs12 -k $KC >/dev/null
# The dev cert chains to Apple's WWDR G3 intermediate, which a fresh keychain doesn't have.
[ -f prov/wwdr.cer ] || curl -fsSL -o prov/wwdr.cer https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
security import prov/wwdr.cer -A -k $KC >/dev/null 2>&1 || true
security set-key-partition-list -S apple-tool:,apple: -s -k "$KP" $KC >/dev/null
security list-keychains -d user -s $KC $(security list-keychains -d user | tr -d '"')
ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1)
security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist
cp prov/Stemify.mobileprovision $APP/embedded.mobileprovision
codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP
security list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain)
security delete-keychain $KC
# Personal build for this device only: stays in prov/ (gitignored), never released.
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)"
fi
+27
View File
@@ -15,6 +15,33 @@
"tintColor": "#FA2D48",
"category": "entertainment",
"versions": [
{
"version": "1.0.12",
"buildVersion": "13",
"date": "2026-10-20T13:29:05-07:00",
"localizedDescription": "More space between the seek bar and the transport buttons on Now Playing. Unsigned IPA: re-sign with Sideloadly, AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.12/stemify-1.0.12.ipa",
"size": 1529939,
"minOSVersion": "17.0"
},
{
"version": "1.0.11",
"buildVersion": "12",
"date": "2026-10-20T12:24:42-07:00",
"localizedDescription": "Tapping the lock screen / Control Center player opens the app again.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.11/stemify-1.0.11.ipa",
"size": 1529241,
"minOSVersion": "17.0"
},
{
"version": "1.0.10",
"buildVersion": "11",
"date": "2026-10-20T11:21:34-07:00",
"localizedDescription": "Shuffle and repeat (list and song) on Now Playing. Playback speed from 0.5x to 2x, pitch kept.\n\nUnsigned IPA, not tested on device.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.10/stemify-1.0.10.ipa",
"size": 1529239,
"minOSVersion": "17.0"
},
{
"version": "1.0.9",
"buildVersion": "10",
+5 -2
View File
@@ -1,8 +1,11 @@
# Build from the repo root so the web player comes along:
# docker build -f server/Dockerfile -t stemserve .
FROM python:3.12-alpine
RUN apk add --no-cache ffmpeg
COPY stemserve.py /app/stemserve.py
COPY server/stemserve.py /app/server/stemserve.py
COPY web /app/web
ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1
EXPOSE 8765
# Unraid shares belong to nobody:users.
USER 99:100
CMD ["python", "/app/stemserve.py"]
CMD ["python", "/app/server/stemserve.py"]
+6 -2
View File
@@ -15,8 +15,9 @@ from the desktop). From an unraid terminal:
```sh
mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
cp -r /path/to/stemify/server /mnt/user/appdata/stemserve/src # or copy over SMB
docker build -t stemserve /mnt/user/appdata/stemserve/src
mkdir -p /mnt/user/appdata/stemserve/src # needs server/ and web/ from the repo
cp -r /path/to/stemify/{server,web} /mnt/user/appdata/stemserve/src/ # or copy over SMB
docker build -f /mnt/user/appdata/stemserve/src/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve/src
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
-v /mnt/user/Music/Stems:/stems:ro \
-v /mnt/user/appdata/stemserve/cache:/cache \
@@ -42,6 +43,9 @@ curl http://192.168.86.2:8765/api/info
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
| `PLEX_URL` | empty | Plex address; the web UI reaches it through `/plex/` on this server, so HTTPS pages work |
| `PLEX_TOKEN`| empty | Plex token for the web UI (set `TOKEN` too) |
| `WEB` | `1` | `0` turns off the web UI |
## API
+3 -1
View File
@@ -1,6 +1,6 @@
services:
stemserve:
build: .
build: {context: .., dockerfile: server/Dockerfile}
image: stemserve:latest
container_name: stemserve
restart: unless-stopped
@@ -14,3 +14,5 @@ services:
environment:
# Optional shared secret; set the same token in the app (Settings > Stem Server).
- TOKEN=
- PLEX_URL=
- PLEX_TOKEN=
+57
View File
@@ -16,6 +16,9 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
/plex/... proxy to PLEX_URL (needs TOKEN or the Plex token)
/api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
/ the web player (web/, or WEB_DIR); no token needed
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
"""
@@ -31,7 +34,9 @@ import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from array import array
from concurrent.futures import ThreadPoolExecutor
from http import HTTPStatus
@@ -43,6 +48,9 @@ PORT = int(os.environ.get("PORT", "8765"))
TOKEN = os.environ.get("TOKEN", "")
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
PLEX_URL = os.environ.get("PLEX_URL", "").rstrip("/")
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
SERVE_WEB = os.environ.get("WEB", "1") != "0" # WEB=0: API only, no web player
MAX_BODY = 8 << 20
MARKER = ".stemify.json"
VERSION = 1
@@ -50,6 +58,10 @@ FFMPEG = shutil.which("ffmpeg")
OPUS_RATES = (64, 96, 128, 160, 192, 256)
PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars
CHUNK = 1 << 20
# The web player. Served without the token; it asks for one in its settings when TOKEN is set.
WEB_DIR = os.path.realpath(os.environ.get("WEB_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web")))
WEB_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json"}
_pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2))
_locks: dict[str, threading.Lock] = {}
@@ -254,6 +266,31 @@ class Handler(BaseHTTPRequestHandler):
return True
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
def _plex_proxy(self, path, rawquery, query):
"""Forward to PLEX_URL so an HTTPS page can reach an HTTP Plex. Needs the stem or Plex token."""
given = self.headers.get("X-Plex-Token") or query.get("X-Plex-Token", [None])[0]
if not (self._authorized(query) or (PLEX_TOKEN and given == PLEX_TOKEN)):
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
pairs = [(k, v) for k, v in urllib.parse.parse_qsl(rawquery, keep_blank_values=True) if k not in ("token", "X-Plex-Token")]
req = urllib.request.Request(PLEX_URL + path + ("?" + urllib.parse.urlencode(pairs) if pairs else ""))
req.add_header("X-Plex-Token", PLEX_TOKEN or given or "")
for h in ("Range", "Accept", "X-Plex-Client-Identifier", "X-Plex-Product", "If-None-Match"):
if self.headers.get(h):
req.add_header(h, self.headers[h])
try:
r = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e:
r = e
except OSError as e:
return self._error(502, f"plex unreachable: {e}")
self.send_response(r.status if hasattr(r, "status") else r.code)
for h in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"):
if r.headers.get(h):
self.send_header(h, r.headers[h])
self.end_headers()
while chunk := r.read(65536):
self.wfile.write(chunk)
def _send(self, status, body=b"", ctype="application/json", headers=None):
self.send_response(status)
self.send_header("Content-Type", ctype)
@@ -267,6 +304,19 @@ class Handler(BaseHTTPRequestHandler):
def _error(self, status, msg):
self._send(status, json.dumps({"error": msg}).encode())
def _web(self, path):
if not SERVE_WEB:
return self._error(404, "not found")
if path == "/":
path = "/index.html"
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
if not full.startswith(WEB_DIR + os.sep) or not os.path.isfile(full):
return self._error(404, "not found")
with open(full, "rb") as f:
body = f.read()
ctype = WEB_TYPES.get(os.path.splitext(full)[1], "application/octet-stream")
self._send(200, body, ctype, {"Cache-Control": "no-cache"})
def do_HEAD(self):
self.do_GET()
@@ -277,6 +327,10 @@ class Handler(BaseHTTPRequestHandler):
url = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(url.query)
path = urllib.parse.unquote(url.path)
if path.startswith("/plex/") and PLEX_URL:
return self._plex_proxy(path[len("/plex"):], url.query, query)
if not path.startswith(("/api/", "/file/")):
return self._web(path)
if not self._authorized(query):
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
try:
@@ -286,6 +340,9 @@ class Handler(BaseHTTPRequestHandler):
"name": "stemserve", "version": VERSION, "tracks": INDEX.count,
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
if path == "/api/config":
plex = {"url": PLEX_URL, "token": PLEX_TOKEN} if PLEX_URL and PLEX_TOKEN else None
return self._send(200, json.dumps({"plex": plex}).encode(), headers={"Cache-Control": "no-store"})
if path == "/api/index":
body, etag = INDEX.get()
if self.headers.get("If-None-Match") == etag:
+874
View File
@@ -0,0 +1,874 @@
// Stemify web player. Browses Plex, plays stems from stemserve, no build step.
// Each stem is its own <audio> element so speed changes keep pitch; a tick keeps them lined up.
const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const shuffled = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const ICON = {
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',
next: '<svg viewBox="0 0 24 24"><rect x="17.5" y="5" width="2.5" height="14" rx="1"/><path d="M4 5.7v12.6a1 1 0 0 0 1.55.83L15 12.83a1 1 0 0 0 0-1.66l-9.45-6.3A1 1 0 0 0 4 5.7z"/></svg>',
shuffle: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4V8h-2.2c-1 0-1.9.5-2.5 1.3L7.6 16c-.9 1.3-2.4 2-4 2H2v-2h1.6c.9 0 1.8-.5 2.3-1.2l4.7-6.7C11.5 6.8 13 6 14.8 6H17V3zM2 6h1.6c1.6 0 3.1.8 4 2l.9 1.3-1.2 1.7-1.4-1.9A2.8 2.8 0 0 0 3.6 8H2V6zm12.3 9.7c.6.8 1.5 1.3 2.5 1.3H17v-3l4 4-4 4v-3h-.2c-1.7 0-3.3-.8-4.2-2.2l-.6-.8 1.2-1.7.1.4z"/></svg>',
repeat: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/></svg>',
repeat1: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/><path d="M11.2 9.6l1.8-.9h1.2v6.6h-1.6v-4.9l-1.4.6z"/></svg>',
mixer: '<svg viewBox="0 0 24 24"><path d="M6 3h2v6.2a3 3 0 0 1 0 5.6V21H6v-6.2a3 3 0 0 1 0-5.6V3zm10 0h2v2.2a3 3 0 0 1 0 5.6V21h-2V10.8a3 3 0 0 1 0-5.6V3zM7 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>',
queue: '<svg viewBox="0 0 24 24"><path d="M3 5h14v2H3zm0 6h14v2H3zm0 6h9v2H3zm13-1.5v-1.5l6 3.5-6 3.5V17z"/></svg>',
busy: '<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2.2A6.8 6.8 0 1 1 12 5.2V3z"/></svg>',
};
// ---- settings --------------------------------------------------------------------------
const KEY = "stemify-web";
const S = Object.assign({
plex: null, // {url, token, name}
section: null,
stemUrl: "", // empty = same origin as this page
stemToken: "",
fmt: "opus160",
volume: 0.85, speed: 1, shuffle: false, repeat: "off",
mix: {}, // stem name -> {level, muted, solo}; kept across tracks
albumSort: "titleSort",
}, load(KEY) || {});
if (!S.clientId) S.clientId = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2);
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
let saveTimer;
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(KEY, JSON.stringify(S));
localStorage.setItem(KEY + "-queue", JSON.stringify({ items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }));
} catch {}
}, 300);
}
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
// ---- Plex ------------------------------------------------------------------------------
const plexHeaders = token => ({
Accept: "application/json",
"X-Plex-Product": "Stemify Web",
"X-Plex-Client-Identifier": S.clientId,
...(token ? { "X-Plex-Token": token } : {}),
});
async function px(path, params = {}) {
if (!S.plex) throw new Error("Not signed in to Plex");
const u = new URL(S.plex.url + path, location.href);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
if (!r.ok) throw new Error(`Plex ${r.status}`);
return (await r.json()).MediaContainer || {};
}
const plexImg = (thumb, size = 300) => thumb && S.plex
? `${S.plex.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${S.plex.token}`
: "";
function plexTrack(m) {
const part = m.Media?.[0]?.Part?.[0];
return {
id: "p" + m.ratingKey, title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
albumKey: m.parentRatingKey, thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
part: part?.key, file: part?.file, index: m.index,
};
}
async function plexSignIn(status) {
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: plexHeaders() })).json();
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
window.open(auth, "_blank", "width=800,height=740");
status("Waiting for Plex sign-in…");
let token = null;
for (let n = 0; n < 150 && !token; n++) {
await new Promise(r => setTimeout(r, 2000));
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: plexHeaders() })).json()).authToken;
}
if (!token) throw new Error("Sign-in timed out");
status("Finding your server…");
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: plexHeaders(token) })).json();
const https = location.protocol === "https:";
for (const srv of res.filter(r => r.provides?.includes("server"))) {
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
for (const c of conns) {
try {
const r = await fetch(`${c.uri}/identity`, { headers: plexHeaders(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
if (r.ok) return { url: c.uri, token: srv.accessToken || token, name: srv.name };
} catch {}
}
}
throw new Error("Signed in, but no Plex server answered from this browser");
}
async function musicSection() {
if (S.section) return S.section;
const secs = (await px("/library/sections")).Directory || [];
S.section = secs.find(d => d.type === "artist")?.key || null;
save();
if (!S.section) throw new Error("No music library on this Plex server");
return S.section;
}
// ---- stemserve -------------------------------------------------------------------------
const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
const stemQuery = extra => {
const q = new URLSearchParams(extra);
if (S.stemToken) q.set("token", S.stemToken);
const s = q.toString();
return s ? "?" + s : "";
};
const keyPath = k => k.split("/").map(encodeURIComponent).join("/");
const fmtParams = () => S.fmt.startsWith("opus") ? { fmt: "opus", br: S.fmt.slice(4) } : {};
// Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's
// suffix identifies a track. Short or duration-mismatched suffixes only count under a root
// learned from an earlier confident match.
const Stems = {
entries: [], bySource: new Map(), roots: [], etag: null, ok: false,
norm(p) {
p = p.normalize("NFC").replaceAll("\\", "/");
const slash = p.lastIndexOf("/"), dot = p.lastIndexOf(".");
if (dot > slash + 1) p = p.slice(0, dot);
return p.toLowerCase();
},
async refresh() {
try {
const r = await fetch(stemBase() + "/api/index" + stemQuery(), { headers: this.etag ? { "If-None-Match": this.etag } : {} });
this.ok = r.ok || r.status === 304;
if (r.status !== 200) return false;
this.etag = r.headers.get("ETag");
this.entries = (await r.json()).tracks || [];
this.bySource = new Map();
for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); }
return true;
} catch { this.ok = false; return false; }
},
find(t) {
if (t.stem) return t.stem;
if (!t.file || !this.entries.length) return null;
const path = this.norm(t.file);
for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; }
const parts = path.split("/");
for (let i = 1; i < parts.length; i++) {
const hit = this.bySource.get(parts.slice(i).join("/"));
if (!hit) continue;
const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000;
if (parts.length - i >= 2 && okDur) {
const root = parts.slice(0, i).join("/") + "/";
if (!this.roots.includes(root)) this.roots.push(root);
return hit;
}
}
return null;
},
url(e, s) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams())}`; },
prepare(e) {
if (e && S.fmt.startsWith("opus")) fetch(`${stemBase()}/api/prepare${stemQuery({ key: e.key, ...fmtParams() })}`, { method: "POST" }).catch(() => {});
},
};
const stemTrack = e => {
const parts = e.key.split("/");
return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e };
};
// ---- mix -------------------------------------------------------------------------------
const unity = () => ({ level: 1, muted: false, solo: false });
const PRESETS = [
["Full Mix", () => ({})],
["Karaoke", n => n.includes("vocals") && { vocals: { muted: true } }],
["A Cappella", n => n.includes("vocals") && { vocals: { solo: true } }],
["No Drums", n => n.includes("drums") && { drums: { muted: true } }],
["Rhythm", n => n.includes("drums") && n.includes("bass") && { drums: { solo: true }, bass: { solo: true } }],
["No Guitar", n => n.includes("guitar") && { guitar: { muted: true } }],
["No Bass", n => n.includes("bass") && { bass: { muted: true } }],
];
const setting = name => ({ ...unity(), ...S.mix[name] });
function stemGain(name, names) {
const s = setting(name), anySolo = names.some(n => setting(n).solo);
return anySolo ? (s.solo ? s.level : 0) : (s.muted ? 0 : s.level);
}
function applyPreset(build, names) {
const m = build(names);
if (!m) return;
S.mix = {};
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
mixChanged();
}
function currentPreset(names) {
const norm = m => JSON.stringify(names.map(n => { const s = { ...unity(), ...m[n] }; return [s.level, s.muted, s.solo]; }));
const now = norm(S.mix);
return PRESETS.find(([, b]) => { const m = b(names); return m && norm(Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { ...unity(), ...v }]))) === now; })?.[0];
}
// ---- audio -----------------------------------------------------------------------------
const E = {
els: [], names: [], gains: [], ctx: null, master: null, graph: false,
want: false, stalled: false, onEnded: null,
audioCtx() {
if (!this.ctx) {
const C = window.AudioContext || window.webkitAudioContext;
if (!C) return null;
this.ctx = new C();
this.master = this.ctx.createGain();
this.master.connect(this.ctx.destination);
this.master.gain.value = S.volume;
}
if (this.ctx.state === "suspended") this.ctx.resume();
return this.ctx;
},
load(sources, startSec, play) {
this.unload();
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
const sameOrigin = sources.every(s => new URL(s.url, location.href).origin === location.origin);
this.graph = sameOrigin && !!this.audioCtx();
for (const s of sources) {
const el = new Audio();
el.preload = "auto";
el.preservesPitch = true;
el.src = s.url;
el.playbackRate = el.defaultPlaybackRate = S.speed;
if (startSec) el.currentTime = startSec;
let gain = null;
if (this.graph) {
gain = this.ctx.createGain();
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
}
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
}
const lead = this.els[0];
lead.addEventListener("ended", () => this.onEnded?.());
lead.addEventListener("durationchange", () => UI.progress());
lead.addEventListener("error", () => { if (this.want) toast("Couldn't load this track"); });
this.applyGains();
this.want = play;
this.stalled = play;
},
unload() {
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
this.els = []; this.names = []; this.gains = [];
},
applyGains() {
const named = this.names.filter(Boolean);
this.els.forEach((el, i) => {
const g = this.names[i] ? stemGain(this.names[i], named) : 1;
if (this.gains[i]) this.gains[i].gain.value = g;
else el.volume = Math.min(1, g * (this.graph ? 1 : S.volume));
});
if (this.master) this.master.gain.value = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); },
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); },
seek(sec) { this.els.forEach(e => { e.currentTime = sec; }); if (this.want) this.stalled = true; UI.progress(); },
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
position() { return this.els[0]?.currentTime || 0; },
duration() { const d = this.els[0]?.duration; return isFinite(d) ? d : (Q.cur()?.duration || 0) / 1000; },
get playing() { return this.want; },
get buffering() { return this.want && this.stalled; },
// Holds everything until every stem can play, then starts them together and keeps followers
// within ~15 ms of the lead by nudging their rate (pitch is preserved, so it's inaudible).
tick() {
if (!this.els.length || !this.want) return;
const lead = this.els[0];
const notReady = this.els.some(e => e.readyState < 3 && !e.ended);
if (notReady) {
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; UI.transport(); }
return;
}
if (this.stalled) {
this.stalled = false;
const t = lead.currentTime;
this.els.forEach(e => { if (e !== lead && Math.abs(e.currentTime - t) > 0.01) e.currentTime = t; e.playbackRate = S.speed; });
Promise.all(this.els.map(e => e.play())).catch(err => {
if (err.name === "NotAllowedError") { this.pause(); UI.transport(); }
});
UI.transport();
return;
}
for (const f of this.els.slice(1)) {
if (f.ended || lead.ended) continue;
const d = f.currentTime - lead.currentTime;
if (Math.abs(d) > 0.25) f.currentTime = lead.currentTime;
else if (Math.abs(d) > 0.015) f.playbackRate = S.speed * (1 - Math.max(-0.06, Math.min(0.06, d * 1.5)));
else if (f.playbackRate !== S.speed) f.playbackRate = S.speed;
}
},
};
setInterval(() => E.tick(), 150);
// ---- queue -----------------------------------------------------------------------------
let uidSeq = Date.now();
const wrap = t => ({ uid: uidSeq++, t });
const Q = {
items: [], orig: null, i: -1,
cur() { return this.items[this.i]?.t; },
playList(tracks, start = 0, shuffle = S.shuffle) {
if (!tracks.length) return;
const all = tracks.map(wrap);
if (shuffle) {
const first = all[start] && start >= 0 ? all[start] : all[Math.floor(Math.random() * all.length)];
this.orig = all;
this.items = [first, ...shuffled(all.filter(x => x !== first))];
this.i = 0;
} else { this.orig = null; this.items = all; this.i = start; }
S.shuffle = shuffle;
this.start(0, true);
},
add(tracks, next = false) {
const w = tracks.map(wrap);
if (next) { this.items.splice(this.i + 1, 0, ...w); this.orig?.push(...w); }
else { this.items.push(...w); this.orig?.push(...w); }
if (this.i < 0) { this.i = 0; this.start(0, false); }
UI.drawer(); save();
toast(tracks.length === 1 ? `Added “${tracks[0].title}”` : `Added ${tracks.length} tracks`);
},
remove(idx) {
if (idx === this.i) return;
const [x] = this.items.splice(idx, 1);
if (this.orig) this.orig = this.orig.filter(o => o !== x);
if (idx < this.i) this.i--;
UI.drawer(); save();
},
jump(idx) { this.i = idx; this.start(0, true); },
start(posSec, play) {
const t = this.cur();
if (!t) { E.unload(); UI.all(); return; }
const e = Stems.find(t);
const srcs = e
? e.stems.map(s => ({ name: s.name, url: Stems.url(e, s) }))
: [{ name: null, url: `${S.plex?.url}${t.part}?X-Plex-Token=${S.plex?.token}` }];
E.load(srcs, posSec, play);
Wave.load(e);
Stems.prepare(e);
const nt = this.items[this.i + 1]?.t;
if (nt) Stems.prepare(Stems.find(nt));
UI.all(); save();
},
next(auto = false) {
if (auto && S.repeat === "one") { E.seek(0); E.play(); return; }
if (this.i + 1 < this.items.length) { this.i++; this.start(0, true); }
else if (S.repeat !== "off" && this.items.length) { this.i = 0; this.start(0, true); }
else if (auto) { E.pause(); E.seek(0); UI.transport(); }
},
prev() {
if (E.position() > 3 || (this.i === 0 && S.repeat === "off")) { E.seek(0); return; }
this.i = this.i > 0 ? this.i - 1 : this.items.length - 1;
this.start(0, E.playing);
},
toggleShuffle() {
if (!S.shuffle) {
this.orig = this.items.slice();
this.items = [...this.items.slice(0, this.i + 1), ...shuffled(this.items.slice(this.i + 1))];
} else if (this.orig) {
const cur = this.items[this.i];
this.items = this.orig;
this.i = Math.max(0, this.items.indexOf(cur));
this.orig = null;
}
S.shuffle = !S.shuffle;
UI.transport(); UI.drawer(); save();
},
cycleRepeat() {
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
UI.transport(); save();
},
};
E.onEnded = () => Q.next(true);
function setSpeed(v) {
S.speed = v;
E.setSpeed(v);
UI.transport(); save();
}
function mixChanged() { E.applyGains(); Wave.draw(); UI.drawer(); save(); }
// ---- waveform --------------------------------------------------------------------------
const Wave = {
peaks: null, key: null, canvas: $("#wave"),
async load(e) {
this.peaks = null; this.key = e?.key || null;
this.draw();
if (!e) return;
try {
const r = await fetch(`${stemBase()}/api/peaks${stemQuery({ key: e.key, n: 300 })}`);
if (!r.ok || this.key !== e.key) return;
this.peaks = (await r.json()).stems;
this.draw();
} catch {}
},
draw() {
const c = this.canvas, dpr = devicePixelRatio || 1;
const w = c.clientWidth, h = c.clientHeight;
if (!w) return;
if (c.width !== w * dpr) { c.width = w * dpr; c.height = h * dpr; }
const x = c.getContext("2d");
x.setTransform(dpr, 0, 0, dpr, 0, 0);
x.clearRect(0, 0, w, h);
const dur = E.duration(), frac = dur ? Math.min(1, E.position() / dur) : 0;
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent");
if (!this.peaks) {
x.fillStyle = "#ffffff22"; x.fillRect(0, h / 2 - 2, w, 4);
x.fillStyle = accent; x.fillRect(0, h / 2 - 2, w * frac, 4);
return;
}
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
const full = new Float32Array(n), mixed = new Float32Array(n);
for (const nm of names) {
const g = stemGain(nm, names);
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
}
const max = Math.sqrt(Math.max(...full)) || 1;
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
for (let b = 0; b < bars; b++) {
const i = Math.floor(b / bars * n);
const v = Math.sqrt(mixed[i]) / max, ghost = Math.sqrt(full[i]) / max;
const cx = b * bw;
x.fillStyle = "#ffffff12";
const gh = Math.max(2, ghost * h);
x.fillRect(cx, (h - gh) / 2, bw - 1, gh);
x.fillStyle = b / bars < frac ? accent : "#ffffff40";
const bh = Math.max(2, v * h);
x.fillRect(cx, (h - bh) / 2, bw - 1, bh);
}
},
};
{
let drag = false;
const at = ev => { const r = Wave.canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) * E.duration(); };
Wave.canvas.addEventListener("pointerdown", ev => { drag = true; Wave.canvas.setPointerCapture(ev.pointerId); E.seek(at(ev)); });
Wave.canvas.addEventListener("pointermove", ev => { if (drag) E.seek(at(ev)); });
Wave.canvas.addEventListener("pointerup", () => { drag = false; save(); });
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
}
// ---- player UI -------------------------------------------------------------------------
let drawerMode = null; // "mixer" | "queue"
const UI = {
all() { this.track(); this.transport(); this.progress(); this.drawer(); markPlaying(); },
track() {
const t = Q.cur();
$("#player").classList.toggle("empty", !t);
$("#np-title").textContent = t?.title || "Nothing playing";
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
const art = t ? plexImg(t.thumb, 120) : "";
if (art) $("#np-art").src = art; else $("#np-art").removeAttribute("src");
document.title = t ? `${t.title} · Stemify` : "Stemify";
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = t ? new MediaMetadata({
title: t.title, artist: t.artist, album: t.album,
artwork: t.thumb ? [{ src: plexImg(t.thumb, 512), sizes: "512x512" }] : [],
}) : null;
}
},
transport() {
const p = $("#b-play");
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
p.classList.toggle("busy", E.buffering);
$("#b-shuffle").classList.toggle("on", S.shuffle);
$("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat;
$("#b-repeat").classList.toggle("on", S.repeat !== "off");
$("#b-repeat").title = `Repeat: ${S.repeat} (R)`;
$("#speed").value = String(S.speed);
$("#speed").classList.toggle("on", S.speed !== 1);
$("#b-mixer").classList.toggle("on", drawerMode === "mixer");
$("#b-queue").classList.toggle("on", drawerMode === "queue");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
},
progress() {
$("#t-pos").textContent = fmtTime(E.position());
$("#t-dur").textContent = fmtTime(E.duration());
Wave.draw();
const d = E.duration();
if (d && navigator.mediaSession?.setPositionState) {
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
}
},
drawer() {
const d = $("#drawer");
d.hidden = !drawerMode;
if (drawerMode === "mixer") d.innerHTML = mixerHtml();
else if (drawerMode === "queue") d.innerHTML = queueHtml();
this.transport();
},
};
function mixerHtml() {
const names = E.names.filter(Boolean);
if (!names.length) return `<h2>Mixer</h2><p class="note">${Q.cur() ? "This track has no stems yet." : "Play something with stems to mix it."}</p>`;
const cur = currentPreset(names);
return `<h2>Mixer</h2>
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
${names.map(n => {
const s = setting(n), g = stemGain(n, names);
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
}).join("")}
<p class="note">The mix carries over to the next track.</p>`;
}
function queueHtml() {
if (!Q.items.length) return `<h2>Queue</h2><p class="note">Empty.</p>`;
return `<h2>Queue</h2>${Q.items.map((x, i) => `
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}">
<img loading="lazy" ${x.t.thumb ? `src="${esc(plexImg(x.t.thumb, 80))}"` : ""} alt="">
<div class="tx"><div>${esc(x.t.title)}</div><div>${esc(x.t.artist)}</div></div>
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
</div>`).join("")}`;
}
$("#drawer").addEventListener("click", ev => {
const b = ev.target.closest("button, .q-item");
if (!b) return;
const names = E.names.filter(Boolean);
if (b.dataset.preset) applyPreset(PRESETS[+b.dataset.preset][1], names);
else if (b.dataset.mute) { const n = b.dataset.mute; S.mix[n] = { ...setting(n), muted: !setting(n).muted }; mixChanged(); }
else if (b.dataset.solo) { const n = b.dataset.solo; S.mix[n] = { ...setting(n), solo: !setting(n).solo }; mixChanged(); }
else if (b.dataset.qx) Q.remove(+b.dataset.qx);
else if (b.dataset.q) Q.jump(+b.dataset.q);
});
$("#drawer").addEventListener("input", ev => {
const n = ev.target.dataset.level;
if (!n) return;
S.mix[n] = { ...setting(n), level: +ev.target.value };
E.applyGains(); Wave.draw(); save();
});
$("#drawer").addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); });
$("#b-play").innerHTML = ICON.play;
$("#b-prev").innerHTML = ICON.prev;
$("#b-next").innerHTML = ICON.next;
$("#b-shuffle").innerHTML = ICON.shuffle;
$("#b-mixer").innerHTML = ICON.mixer;
$("#b-queue").innerHTML = ICON.queue;
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
$("#volume").value = S.volume;
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); UI.transport(); save(); };
const toggleDrawer = m => { drawerMode = drawerMode === m ? null : m; UI.drawer(); };
$("#b-play").onclick = togglePlay;
$("#b-prev").onclick = () => Q.prev();
$("#b-next").onclick = () => Q.next();
$("#b-shuffle").onclick = () => Q.toggleShuffle();
$("#b-repeat").onclick = () => Q.cycleRepeat();
$("#b-mixer").onclick = () => toggleDrawer("mixer");
$("#b-queue").onclick = () => toggleDrawer("queue");
$("#speed").onchange = ev => setSpeed(+ev.target.value);
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler("play", () => { E.play(); UI.transport(); });
ms.setActionHandler("pause", () => { E.pause(); UI.transport(); });
ms.setActionHandler("previoustrack", () => Q.prev());
ms.setActionHandler("nexttrack", () => Q.next());
try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {}
}
document.addEventListener("keydown", ev => {
if (ev.target.closest("input, select, textarea") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
const k = ev.key.toLowerCase();
if (k === " ") { ev.preventDefault(); togglePlay(); }
else if (k === "arrowright") E.seek(Math.min(E.duration(), E.position() + (ev.shiftKey ? 30 : 5)));
else if (k === "arrowleft") E.seek(Math.max(0, E.position() - (ev.shiftKey ? 30 : 5)));
else if (k === "s") Q.toggleShuffle();
else if (k === "r") Q.cycleRepeat();
else if (k === "m") toggleDrawer("mixer");
else if (k === "q") toggleDrawer("queue");
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
});
(function frame() {
if (E.playing) {
$("#t-pos").textContent = fmtTime(E.position());
Wave.draw();
}
requestAnimationFrame(frame);
})();
setInterval(() => { if (E.playing) { UI.progress(); save(); } }, 5000);
function toast(msg) {
let t = $("#toast");
if (!t) {
t = document.createElement("div");
t.id = "toast";
t.style.cssText = "position:fixed;left:50%;bottom:calc(var(--player-h) + 16px);transform:translateX(-50%);background:var(--raised);padding:9px 16px;border-radius:999px;box-shadow:0 8px 30px #0008;z-index:9;transition:opacity .3s";
document.body.append(t);
}
t.textContent = msg;
t.style.opacity = 1;
clearTimeout(t._h);
t._h = setTimeout(() => { t.style.opacity = 0; }, 2200);
}
// ---- views -----------------------------------------------------------------------------
const view = $("#view");
let lists = {}; // track lists rendered in the current view, by id
let routeSeq = 0;
function markPlaying() {
const id = Q.cur()?.id;
view.querySelectorAll("tr[data-id]").forEach(r => r.classList.toggle("playing", r.dataset.id === id));
}
function card(href, img, t, s, round = false) {
return `<a class="card ${round ? "round" : ""}" href="${href}">${img ? `<img loading="lazy" src="${esc(img)}" alt="">` : `<div class="ph"></div>`}
<div class="t">${esc(t)}</div><div class="s">${esc(s || "")}</div></a>`;
}
function trackTable(listId, tracks, { numbers = false, album = true } = {}) {
lists[listId] = tracks;
return `<table class="tracks"><tbody>${tracks.map((t, i) => `
<tr data-list="${listId}" data-i="${i}" data-id="${esc(t.id)}">
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
<td class="ti">${esc(t.title)}${Stems.find(t) ? '<span class="stem-dot">STEMS</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
<td class="d">${fmtTime(t.duration / 1000)}</td>
<td><button class="add" data-add="${i}" title="Add to queue (shift: play next)">+</button></td>
</tr>`).join("")}</tbody></table>`;
}
view.addEventListener("click", ev => {
const add = ev.target.closest("[data-add]");
const row = ev.target.closest("tr[data-list]");
if (add && row) { Q.add([lists[row.dataset.list][+add.dataset.add]], ev.shiftKey); return; }
if (row) { Q.playList(lists[row.dataset.list], +row.dataset.i, false); return; }
const act = ev.target.closest("[data-play]");
if (act) {
const tracks = lists[act.dataset.play];
if (act.dataset.mode === "queue") Q.add(tracks);
else Q.playList(tracks, act.dataset.mode === "shuffle" ? -1 : 0, act.dataset.mode === "shuffle");
}
});
const playButtons = id => `<div class="actions">
<button class="btn primary" data-play="${id}">${ICON.play} Play</button>
<button class="btn" data-play="${id}" data-mode="shuffle">${ICON.shuffle} Shuffle</button>
<button class="btn" data-play="${id}" data-mode="queue">${ICON.queue} Add to queue</button></div>`;
function needPlex() {
view.innerHTML = `<h1>Welcome</h1><p class="sub">Sign in to Plex in <a href="#/settings" style="color:var(--accent)">Settings</a> to browse your library, or open <a href="#/stems" style="color:var(--accent)">Stems only</a>.</p>`;
}
// Grid of Plex items, loaded 120 at a time as you scroll.
async function pagedGrid(seq, path, params, cardFn) {
const grid = $(".grid", view), more = document.createElement("div");
more.className = "more";
grid.after(more);
let start = 0, total = Infinity, busy = false;
const next = async () => {
if (busy || start >= total || seq !== routeSeq) return;
busy = true;
const mc = await px(path, { ...params, "X-Plex-Container-Start": start, "X-Plex-Container-Size": 120 });
if (seq !== routeSeq) return;
total = mc.totalSize ?? mc.size ?? 0;
const items = mc.Metadata || [];
start += items.length;
if (!items.length) total = start;
grid.insertAdjacentHTML("beforeend", items.map(cardFn).join(""));
if (!start) grid.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
busy = false;
if (more.getBoundingClientRect().top < innerHeight * 1.5) next();
};
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
await next();
}
const albumCard = m => card(`#/album/${m.ratingKey}`, plexImg(m.thumb), m.title, [m.parentTitle, m.year].filter(Boolean).join(" · "));
const routes = {
async albums(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Albums</h1>
<select id="sort">${[["titleSort", "Title"], ["addedAt:desc", "Recently added"], ["year:desc", "Year"], ["artist.titleSort", "Artist"]]
.map(([v, l]) => `<option value="${v}" ${S.albumSort === v ? "selected" : ""}>${l}</option>`).join("")}</select></div><div class="grid"></div>`;
$("#sort").onchange = ev => { S.albumSort = ev.target.value; save(); route(); };
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 9, sort: S.albumSort }, albumCard);
},
async artists(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Artists</h1></div><div class="grid"></div>`;
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 8, sort: "titleSort" },
m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true));
},
async playlists() {
if (!S.plex) return needPlex();
const pls = (await px("/playlists", { playlistType: "audio" })).Metadata || [];
view.innerHTML = `<div class="bar"><h1>Playlists</h1></div>` + (pls.length
? `<div class="grid">${pls.map(p => card(`#/playlist/${p.ratingKey}`, plexImg(p.composite || p.thumb), p.title, `${p.leafCount} tracks`)).join("")}</div>`
: `<p class="empty-msg">No music playlists.</p>`);
},
async album(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {}, tracks = (kids.Metadata || []).map(plexTrack);
const stems = tracks.filter(t => Stems.find(t)).length;
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Album</div><h1>${esc(a.title)}</h1>
<div class="sub"><a href="#/artist/${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${tracks.length} tracks${stems ? ` · ${stems} with stems` : ""}</div>
${playButtons("album")}</div></div>${trackTable("album", tracks, { numbers: true, album: false })}`;
},
async artist(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {};
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="" style="border-radius:50%">
<div class="meta"><div class="kind">Artist</div><h1>${esc(a.title)}</h1>${playButtons("artist")}</div></div>
<h2>Albums</h2><div class="grid">${(kids.Metadata || []).map(albumCard).join("")}</div>`;
lists.artist = [];
const all = await px(`/library/metadata/${key}/allLeaves`);
if (seq === routeSeq) lists.artist = (all.Metadata || []).map(plexTrack);
},
async playlist(seq, key) {
const [info, items] = await Promise.all([px(`/playlists/${key}`), px(`/playlists/${key}/items`)]);
const p = info.Metadata?.[0] || {}, tracks = (items.Metadata || []).map(plexTrack);
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(p.composite || p.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Playlist</div><h1>${esc(p.title)}</h1><div class="sub">${tracks.length} tracks</div>${playButtons("playlist")}</div></div>
${trackTable("playlist", tracks)}`;
},
async search(seq, q) {
if (!S.plex) return needPlex();
const hubs = (await px("/hubs/search", { query: q, limit: 12, sectionId: await musicSection() })).Hub || [];
const hub = t => hubs.find(h => h.type === t)?.Metadata || [];
const artists = hub("artist"), albums = hub("album"), tracks = hub("track").map(plexTrack);
view.innerHTML = `<div class="bar"><h1>“${esc(q)}”</h1></div>` +
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true)).join("")}</div>` : "") +
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
(tracks.length ? `<h2>Songs</h2>${trackTable("search", tracks)}` : "") +
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
},
async stems(seq) {
if (!Stems.entries.length) await Stems.refresh();
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
view.innerHTML = `<div class="bar"><h1>Stems only</h1><input type="text" id="sf" placeholder="Filter ${all.length} tracks"></div><div id="sl"></div>`;
const render = () => {
const f = $("#sf").value.toLowerCase();
const hits = f ? all.filter(t => t.stem.key.toLowerCase().includes(f)) : all;
$("#sl").innerHTML = (hits.length ? trackTable("stems", hits.slice(0, 400)) : `<p class="empty-msg">${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}</p>`) +
(hits.length > 400 ? `<p class="note">Showing 400 of ${hits.length}. Filter to narrow it down.</p>` : "");
markPlaying();
};
$("#sf").oninput = render;
render();
},
async settings() {
const info = await fetch(stemBase() + "/api/info" + stemQuery()).then(r => r.ok ? r.json() : null).catch(() => null);
let secs = [];
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form">
<p class="note">${S.plex ? `Plex: <b>${esc(S.plex.env ? "via this server" : S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}</b>${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}</p>
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400" value="${esc(S.plex?.url || "")}"></label>
<label>Token<input type="password" id="ptok" value="${esc(S.plex?.token || "")}"></label>
<div class="row"><button class="btn" id="psave">Save</button></div></div></details>
</div>
<h2>Stem server</h2><div class="form">
<p class="note">${info ? `stemserve v${info.version}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : "Can't reach stemserve."}</p>
<label>URL (blank = this page's server)<input type="url" id="surl" placeholder="${esc(location.origin)}" value="${esc(S.stemUrl)}"></label>
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
<label>Stream format<select id="fmt">${[["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]
.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
<div class="row"><button class="btn" id="ssave">Save</button></div>
</div>
<h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · Q queue</p>`;
$("#signin").onclick = async () => {
const st = $("#sstatus");
try { S.plex = await plexSignIn(m => { st.textContent = m; }); S.section = null; save(); route(); }
catch (e) { st.textContent = e.message; }
};
if ($("#signout")) $("#signout").onclick = () => { S.plex = null; S.section = null; save(); route(); };
if ($("#sec")) $("#sec").onchange = ev => { S.section = ev.target.value; save(); };
$("#psave").onclick = () => {
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
S.plex = url && token ? { url, token, name: "" } : null; S.section = null; save(); route();
};
$("#ssave").onclick = async () => {
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); S.fmt = $("#fmt").value;
Stems.etag = null; save(); await Stems.refresh(); route();
};
},
};
async function route() {
const seq = ++routeSeq;
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
const arg = decodeURIComponent(rest.join("/"));
document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name));
lists = {};
view.scrollTop = 0;
try { await (routes[name] || routes.albums)(seq, arg); }
catch (e) { if (seq === routeSeq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
if (seq === routeSeq) markPlaying();
}
addEventListener("hashchange", route);
$("#search").onsubmit = ev => {
ev.preventDefault();
const q = ev.target.q.value.trim();
if (q) location.hash = "#/search/" + encodeURIComponent(q);
};
// Plex from the server's PLEX_URL / PLEX_TOKEN when nothing was signed in by hand, and a
// local stemserve when this page's own server has none.
async function autoConnect() {
const ok = await Stems.refresh();
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
try {
const r = await fetch(u + "/api/info" + stemQuery(), { signal: AbortSignal.timeout(1500) });
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
} catch {}
}
}
if (!S.plex || S.plex.env) {
try {
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null);
if (c?.plex) S.plex = { token: c.plex.token, url: stemBase() + "/plex", name: "", env: true };
else if (S.plex?.env) S.plex = null;
save();
} catch {}
}
}
// ---- start -----------------------------------------------------------------------------
(async () => {
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
const saved = load(KEY + "-queue");
if (saved?.items?.length) {
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
uidSeq = Math.max(uidSeq, ...saved.items.map(x => x.uid + 1));
if (Q.orig) Q.orig = Q.orig.map(o => Q.items.find(x => x.uid === o.uid) || o);
Q.start(saved.pos || 0, false);
}
UI.all();
route();
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
})();
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0e12">
<title>Stemify</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<aside id="nav">
<div class="brand">stemify</div>
<a href="#/albums" data-nav="albums">Albums</a>
<a href="#/artists" data-nav="artists">Artists</a>
<a href="#/playlists" data-nav="playlists">Playlists</a>
<a href="#/stems" data-nav="stems">Stems only</a>
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
<a href="#/settings" data-nav="settings" class="settings-link">Settings</a>
</aside>
<main id="view"></main>
<aside id="drawer" hidden></aside>
</div>
<footer id="player" class="empty">
<div class="np">
<img id="np-art" alt="">
<div class="np-text"><div id="np-title"></div><div id="np-sub"></div></div>
</div>
<div class="center">
<div class="transport">
<button id="b-shuffle" class="side" title="Shuffle (S)"></button>
<button id="b-prev" title="Previous"></button>
<button id="b-play" class="play" title="Play (Space)"></button>
<button id="b-next" title="Next"></button>
<button id="b-repeat" class="side" title="Repeat (R)"></button>
</div>
<div class="seek">
<span id="t-pos">0:00</span>
<canvas id="wave"></canvas>
<span id="t-dur">0:00</span>
</div>
</div>
<div class="right">
<select id="speed" title="Speed"></select>
<button id="b-mixer" class="pill" title="Mixer (M)"></button>
<button id="b-queue" class="pill" title="Queue (Q)"></button>
<input id="volume" type="range" min="0" max="1" step="0.01" title="Volume">
</div>
</footer>
<script type="module" src="app.js"></script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
:root {
--bg: #0d0e12;
--panel: #15171d;
--raised: #1d2028;
--line: #262a33;
--text: #eceef3;
--muted: #8a8f9c;
--accent: #f2b544;
--accent-ink: #1a1306;
--radius: 12px;
--player-h: 96px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; }
button, input, select { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; padding: 0; }
a { color: inherit; text-decoration: none; }
svg { width: 1em; height: 1em; fill: currentColor; display: block; }
#app { display: grid; grid-template-columns: 210px 1fr auto; height: calc(100% - var(--player-h)); }
#nav { background: var(--panel); border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 2px; }
.brand { white-space: nowrap; font-weight: 800; font-size: 20px; letter-spacing: -0.02em; padding: 0 10px 18px; }
.brand::after { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-left: 3px; }
#nav a { padding: 8px 10px; border-radius: 8px; color: var(--muted); font-weight: 500; }
#nav a:hover { color: var(--text); }
#nav a.on { background: var(--raised); color: var(--text); }
#search input { width: 100%; margin: 12px 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; outline: none; }
#search input:focus { border-color: var(--accent); }
.settings-link { margin-top: auto; }
#view { overflow-y: auto; padding: 28px 32px 48px; }
h1 { font-size: 28px; letter-spacing: -0.02em; margin: 0 0 4px; }
h2 { font-size: 17px; margin: 28px 0 12px; }
.sub { color: var(--muted); }
.bar { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; flex-wrap: wrap; }
.bar h1 { margin-right: auto; }
select, input[type=text], input[type=url], input[type=password] { background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 18px; }
.card { display: block; min-width: 0; }
.card img, .card .ph { width: 100%; aspect-ratio: 1; border-radius: 10px; object-fit: cover; background: var(--raised); display: block; transition: transform .15s; }
.card:hover img { transform: translateY(-2px); }
.card.round img, .card.round .ph { border-radius: 50%; }
.card .t { margin-top: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card .s { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hero { display: flex; gap: 28px; align-items: flex-end; margin-bottom: 26px; }
.hero img { width: 210px; height: 210px; border-radius: var(--radius); object-fit: cover; background: var(--raised); box-shadow: 0 20px 50px #0008; }
.hero .meta { min-width: 0; }
.hero .kind { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.btn { background: var(--raised); border-radius: 999px; padding: 9px 18px; font-weight: 600; display: inline-flex; gap: 8px; align-items: center; }
.btn.primary { background: var(--accent); color: var(--accent-ink); }
.btn:hover { filter: brightness(1.12); }
.tracks { width: 100%; border-collapse: collapse; }
.tracks tr { border-radius: 8px; }
.tracks td { padding: 8px 10px; border-bottom: 1px solid #ffffff08; white-space: nowrap; }
.tracks tr:hover td { background: var(--panel); }
.tracks tr.playing td { color: var(--accent); }
.tracks .n { width: 36px; color: var(--muted); text-align: right; }
.tracks .ti { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tracks .ti small { color: var(--muted); margin-left: 8px; }
.tracks .d { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.tracks .add { opacity: 0; color: var(--muted); font-size: 18px; }
.tracks tr:hover .add { opacity: 1; }
.tracks .add:hover { color: var(--text); }
.stem-dot { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--accent); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; margin-left: 8px; vertical-align: 1px; }
.more { display: block; margin: 24px auto; }
.empty-msg { color: var(--muted); padding: 40px 0; }
.form { max-width: 520px; display: grid; gap: 14px; }
.form label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
.form label input, .form label select { color: var(--text); font-size: 14px; }
.form .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.note { color: var(--muted); font-size: 13px; }
#drawer { width: 340px; background: var(--panel); border-left: 1px solid var(--line); overflow-y: auto; padding: 22px 20px; }
#drawer h2 { margin: 0 0 14px; }
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.chip { background: var(--raised); border-radius: 999px; padding: 5px 12px; font-size: 13px; font-weight: 500; }
.chip.on { background: var(--accent); color: var(--accent-ink); }
.chip:disabled { opacity: .35; cursor: default; }
.stem { display: grid; grid-template-columns: 1fr auto auto; gap: 8px 6px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); }
.stem .name { text-transform: capitalize; font-weight: 600; }
.stem.silent .name { color: var(--muted); }
.stem input { grid-column: 1 / -1; width: 100%; }
.ms { width: 30px; height: 26px; border-radius: 6px; background: var(--raised); font-size: 12px; font-weight: 700; color: var(--muted); }
.ms.on { background: var(--text); color: var(--bg); }
.ms.solo.on { background: var(--accent); color: var(--accent-ink); }
.q-item { display: flex; gap: 10px; align-items: center; padding: 7px 6px; border-radius: 8px; cursor: pointer; }
.q-item:hover { background: var(--raised); }
.q-item img { width: 38px; height: 38px; border-radius: 6px; object-fit: cover; background: var(--raised); flex: none; }
.q-item .tx { min-width: 0; flex: 1; }
.q-item .tx div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q-item .tx div + div { color: var(--muted); font-size: 12px; }
.q-item.cur { background: var(--raised); }
.q-item.cur .tx div:first-child { color: var(--accent); }
.q-item.past { opacity: .45; }
.q-item .x { color: var(--muted); font-size: 18px; padding: 0 4px; opacity: 0; }
.q-item:hover .x { opacity: 1; }
#player { position: fixed; left: 0; right: 0; bottom: 0; height: var(--player-h); background: #101116ee; backdrop-filter: blur(20px);
border-top: 1px solid var(--line); display: grid; grid-template-columns: minmax(180px, 1fr) minmax(320px, 1.6fr) minmax(180px, 1fr); align-items: center; gap: 20px; padding: 0 20px; }
#player.empty .center, #player.empty .right { opacity: .35; pointer-events: none; }
.np { display: flex; gap: 12px; align-items: center; min-width: 0; }
#np-art { width: 58px; height: 58px; border-radius: 8px; object-fit: cover; background: var(--raised); flex: none; }
#np-art:not([src]) { display: none; }
.np-text { min-width: 0; }
#np-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#np-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.center { display: grid; gap: 4px; }
.transport { display: flex; justify-content: center; align-items: center; gap: 18px; font-size: 20px; }
.transport button { color: var(--text); }
.transport .side { color: var(--muted); font-size: 17px; position: relative; }
.transport .side.on { color: var(--accent); }
.transport .side.on::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; }
.transport .play { width: 40px; height: 40px; border-radius: 50%; background: var(--text); color: var(--bg); display: grid; place-items: center; font-size: 18px; }
.transport .play.busy svg { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.seek { display: grid; grid-template-columns: 42px 1fr 42px; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.seek span:last-child { text-align: right; }
#wave { width: 100%; height: 32px; cursor: pointer; display: block; }
.right { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
.pill { color: var(--muted); font-size: 18px; width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; }
.pill.on { background: var(--raised); color: var(--accent); }
#speed { padding: 5px 6px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
#speed.on { color: var(--accent); border-color: var(--accent); }
#volume { width: 96px; }
input[type=range] { accent-color: var(--accent); }
@media (max-width: 860px) {
:root { --player-h: 150px; }
#app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
#nav { flex-direction: row; overflow-x: auto; padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--line); align-items: center; }
.brand { padding: 0 8px 0 4px; }
#nav a { white-space: nowrap; }
#search input { margin: 0; width: 120px; }
.settings-link { margin-top: 0; }
#view { padding: 20px 16px 40px; }
#drawer { position: fixed; inset: auto 0 var(--player-h) 0; width: auto; max-height: 60vh; border-left: 0; border-top: 1px solid var(--line); z-index: 5; }
#player { grid-template-columns: 1fr auto; grid-template-rows: auto auto; gap: 8px 12px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
.center { grid-column: 1 / -1; grid-row: 2; }
.right #volume { display: none; }
.hero { flex-direction: column; align-items: flex-start; }
.hero img { width: 160px; height: 160px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 16px 12px; }
}