15 Commits
Author SHA1 Message Date
cdnutterandClaude Opus 5.5 c17d6f174b ios: 1.0.15, mixer rows stretch to fill spare height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:22:27 -07:00
cdnutterandClaude Sonnet 5.5 e7e4fcc9d6 ios: 1.0.14, source.json, README for tab bar, AirPlay and debug logs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:14:55 -07:00
cdnutterandClaude Sonnet 5.5 926ad6ee99 ios: reorderable tab bar with more tabs, debug log export, AirPlay picker, more room above seeker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:11:58 -07:00
cdnutterandClaude Sonnet 5.5 a0fe35a8d8 iOS: source.json for 1.0.13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:42:31 -07:00
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
21 changed files with 733 additions and 63 deletions

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ ios/build/
ios/Stemify.xcodeproj/ ios/Stemify.xcodeproj/
ios/Stemify/Info.plist ios/Stemify/Info.plist
ios/Stemify/Stemify.entitlements ios/Stemify/Stemify.entitlements
ios/prov
+12
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 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. - **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. - **stemserve** serves the finished stems over HTTP with range requests, waveforms and optional Opus transcodes.
- **Plex** handles the library, metadata, artwork and playlists. - **Plex** handles the library, metadata, artwork and playlists.
@@ -132,6 +138,9 @@ To update, copy the new files, rebuild, `docker rm -f` the container and run it
| `PORT` | `8765` | Listen port | | `PORT` | `8765` | Listen port |
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` | | `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder | | `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `PLEX_URL` | empty | Plex address. With `PLEX_TOKEN`, the web UI connects to Plex without signing in |
| `PLEX_TOKEN` | empty | Plex token. Only handed out to clients that pass `TOKEN`, so set `TOKEN` too |
| `WEB` | `1` | Set to `0` to turn off the web UI and serve the API only |
</details> </details>
@@ -162,6 +171,9 @@ CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free
- **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. - **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. - **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
- **AirPlay:** the AirPlay button on Now Playing picks the speaker or TV. Stems are mixed on the phone, so mixer changes apply live while it plays over AirPlay.
- **Tab bar:** Settings → Tab Bar. Pick and reorder up to five of Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads and Settings. Settings always stays.
- **Debug logs:** Settings → Debug. Turn on Verbose Logging, then View, Export or Clear the log. Export opens the share sheet with the app version, device, iOS version and audio output at the top. Tokens are stripped from the log.
- **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. - **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. - **Playlists:** the Playlists tab switches between Plex and Stem.
- Plex playlists sync with Plex and Plexamp. - Plex playlists sync with Plex and Plexamp.
+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) }
}
+98
View File
@@ -0,0 +1,98 @@
import AVFoundation
import Foundation
import SwiftUI
import UIKit
/// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written.
final class Log: @unchecked Sendable {
static let shared = Log()
enum Level: String { case debug = "D", info = "I", error = "E" }
static let verboseKey = "verboseLogging"
static var verbose: Bool {
get { UserDefaults.standard.bool(forKey: verboseKey) }
set { UserDefaults.standard.set(newValue, forKey: verboseKey) }
}
private let lock = NSLock()
private let url: URL
private let maxBytes = 512 * 1024
private let stamp: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MM-dd HH:mm:ss.SSS"
return f
}()
private init() {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
url = dir.appendingPathComponent("stemify.log")
}
private static let secret = try! NSRegularExpression(
pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive])
static func redact(_ s: String) -> String {
secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2<redacted>")
}
func write(_ level: Level, _ tag: String, _ msg: String) {
if level == .debug && !Log.verbose { return }
let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n"
lock.lock(); defer { lock.unlock() }
guard let data = line.data(using: .utf8) else { return }
let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0
if size > maxBytes { trim() }
if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) }
}
/// Keeps the newest half of the file.
private func trim() {
guard let all = try? Data(contentsOf: url) else { return }
let tail = all.suffix(maxBytes / 2)
let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex
try? Data(tail[cut...]).write(to: url)
}
private func append(_ data: Data) {
guard let h = try? FileHandle(forWritingTo: url) else { return }
defer { try? h.close() }
_ = try? h.seekToEnd()
try? h.write(contentsOf: data)
}
func text() -> String {
lock.lock(); defer { lock.unlock() }
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
}
func clear() {
lock.lock(); defer { lock.unlock() }
try? FileManager.default.removeItem(at: url)
}
/// Device and app details followed by the log, written to a temp file for the share sheet.
func export() -> URL {
let b = Bundle.main
let d = UIDevice.current
var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") "
head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
head += "Output: \(AirRoute.describe())\n\n"
let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt")
try? (head + text()).write(to: out, atomically: true, encoding: .utf8)
return out
}
func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } }
func info(_ tag: String, _ msg: String) { write(.info, tag, msg) }
func error(_ tag: String, _ msg: String) { write(.error, tag, msg) }
}
/// Where audio is currently going (speaker, AirPlay, Bluetooth…).
enum AirRoute {
static func describe() -> String {
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" }
return outs.isEmpty ? "none" : outs.joined(separator: ", ")
}
}
+2 -1
View File
@@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable {
private static let keys = [ private static let keys = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
] ]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T { private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
@@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable {
/// Plex path prefixes that matched the stems index, newline-separated. /// Plex path prefixes that matched the stems index, newline-separated.
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } } var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } } var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } } var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } } var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
+1 -1
View File
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
} }
} catch { } catch {
if !released { if !released {
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription) Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
state.with { _error = error } state.with { _error = error }
} }
ring.eos = true ring.eos = true
@@ -181,6 +181,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car). /// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
func report(_ text: String) { func report(_ text: String) {
Log.shared.error("report", text)
message = AppMessage(text: text) message = AppMessage(text: text)
messageListeners.values.forEach { $0(text) } messageListeners.values.forEach { $0(text) }
} }
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let self, let info = n.userInfo, guard let self, let info = n.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
switch type { switch type {
case .began: case .began:
// A stale "began" arrives after the app was suspended; the session wasn't really taken. // A stale "began" arrives after the app was suspended; the session wasn't really taken.
@@ -813,12 +815,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return } let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())")
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker. // Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() } if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
self.eq.refreshRoute() self.eq.refreshRoute()
} }
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
guard let self else { return } guard let self else { return }
Log.shared.error("session", "media services reset")
let was = self.playWhenReady let was = self.playWhenReady
let pos = self.positionMs() let pos = self.positionMs()
self.currentToken = -1 self.currentToken = -1
+18
View File
@@ -70,6 +70,24 @@ final class StemPlaylists {
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix" 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) { private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
guard let i = lists.firstIndex(where: { $0.id == id }) else { return } guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
change(&lists[i]) change(&lists[i])
+20
View File
@@ -1,4 +1,6 @@
import AVKit
import SwiftUI import SwiftUI
import UIKit
let MARGIN: CGFloat = 20 let MARGIN: CGFloat = 20
@@ -330,3 +332,21 @@ private struct ListRow: ViewModifier {
} }
} }
} }
/// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay.
struct AirPlayButton: UIViewRepresentable {
var tint: UIColor = .label
var active: UIColor = .systemBlue
func makeUIView(context: Context) -> AVRoutePickerView {
let v = AVRoutePickerView()
v.prioritizesVideoDevices = false
v.backgroundColor = .clear
return v
}
func updateUIView(_ v: AVRoutePickerView, context: Context) {
v.tintColor = tint
v.activeTintColor = active
}
}
+43 -5
View File
@@ -1,11 +1,32 @@
import SwiftUI import SwiftUI
enum Tab: String, CaseIterable, Hashable { enum Tab: String, CaseIterable, Hashable {
case Library, Playlists, Search, Downloads, Settings case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings
static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings]
/// iOS folds anything past five tabs into "More", so the bar holds at most this many.
static let maxVisible = 5
/// Parses the saved comma list; Settings is always kept so the app can't lock itself out.
static func layout(_ raw: String) -> [Tab] {
var out: [Tab] = []
for part in raw.split(separator: ",") {
if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) }
}
if out.isEmpty { return defaultLayout }
if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] }
return Array(out.prefix(maxVisible))
}
static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") }
var icon: String { var icon: String {
switch self { switch self {
case .Library: "square.stack.fill" case .Library: "square.stack.fill"
case .Songs: "music.note"
case .Albums: "square.stack"
case .Artists: "music.mic"
case .Genres: "guitars.fill"
case .Playlists: "music.note.list" case .Playlists: "music.note.list"
case .Search: "magnifyingglass" case .Search: "magnifyingglass"
case .Downloads: "arrow.down.circle.fill" case .Downloads: "arrow.down.circle.fill"
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, Hashable {
} }
enum Route: Hashable { enum Route: Hashable {
case Artists, Albums, Songs, Playlists case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case PlexAccount, StemServer, Quality, Storage, Appearance case PlexAccount, StemServer, Quality, Storage, Appearance
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
} }
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
@@ -47,13 +68,30 @@ final class Ui {
if t == tab { paths[t] = [] } else { tab = t } if t == tab { paths[t] = [] } else { tab = t }
} }
/// The tabs currently in the bar, in order.
var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) }
/// Where library pages open: Library if it's in the bar, else the first tab that is.
private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) }
/// Downloads as its own tab, or pushed onto the current one when it's hidden.
func openDownloads() {
if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) }
}
/// Falls back to the first tab when the selected one was just removed from the bar.
func ensureValidTab() {
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
}
func openNowPlaying() { nowPlayingOpen = true } func openNowPlaying() { nowPlayingOpen = true }
/// Switches to the Library tab and pushes [r] there. /// Switches to the Library tab and pushes [r] there.
func showInLibrary(_ r: Route) { func showInLibrary(_ r: Route) {
nowPlayingOpen = false nowPlayingOpen = false
tab = .Library let t = home
paths[.Library, default: []].append(r) tab = t
paths[t, default: []].append(r)
} }
/// Switches to Settings and opens [r] (the Equalizer) there. /// Switches to Settings and opens [r] (the Equalizer) there.
+10 -1
View File
@@ -9,7 +9,7 @@ struct RootView: View {
let s = g.settings let s = g.settings
let p = Palette.of(s.theme, s.accent) let p = Palette.of(s.theme, s.accent)
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) { TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.allCases, id: \.self) { t in ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
NavigationStack(path: ui.path(t)) { NavigationStack(path: ui.path(t)) {
root(t).miniPlayerRoom() root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() } .navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
@@ -20,6 +20,7 @@ struct RootView: View {
} }
} }
.modifier(GlassMiniPlayerHost()) .modifier(GlassMiniPlayerHost())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen() NowPlayingScreen()
.overlayHost(ui.overlays, level: 1) .overlayHost(ui.overlays, level: 1)
@@ -41,6 +42,10 @@ struct RootView: View {
@ViewBuilder private func root(_ t: Tab) -> some View { @ViewBuilder private func root(_ t: Tab) -> some View {
switch t { switch t {
case .Library: LibraryHome() case .Library: LibraryHome()
case .Songs: SongsScreen()
case .Albums: AlbumsScreen()
case .Artists: ArtistsScreen()
case .Genres: GenresScreen()
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Search: SearchScreen() case .Search: SearchScreen()
case .Downloads: DownloadsScreen() case .Downloads: DownloadsScreen()
@@ -54,6 +59,10 @@ struct RootView: View {
case .Albums: AlbumsScreen() case .Albums: AlbumsScreen()
case .Songs: SongsScreen() case .Songs: SongsScreen()
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen()
case .Downloads: DownloadsScreen()
case .TabBar: TabBarScreen()
case .GenrePage(let g): GenreScreen(genre: g)
case .Eq: EqScreen() case .Eq: EqScreen()
case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilters(let id): EqFiltersScreen(profileId: id)
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
@@ -642,3 +642,56 @@ struct DownloadsScreen: View {
} }
} }
} }
// ---- genres -----------------------------------------------------------------------------------
struct GenresScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
var body: some View {
Page(title: "Genres") {
Loaded(state: albums, retry: { reload += 1 }) { list in
let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count)
LazyVStack(spacing: 0) {
ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in
Button { ui.push(.GenrePage(g)) } label: {
HStack {
Text(g).foregroundStyle(p.label)
Spacer()
Text("\(counts[g] ?? 0)").foregroundStyle(p.label2)
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2)
}
.padding(.horizontal, 16).padding(.vertical, 12)
}
.buttonStyle(.plain)
Divider().padding(.leading, 16)
}
}
}
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
}
}
}
struct GenreScreen: View {
@Environment(Ui.self) private var ui
let genre: String
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
var body: some View {
Page(title: genre) {
Loaded(state: albums, retry: { reload += 1 }) { list in
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
}
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
}
}
}
+92 -47
View File
@@ -98,21 +98,32 @@ struct NowPlayingScreen: View {
let pm = g.playback let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base let c = tint.map { base.tinted($0) } ?? base
let track = pm.current?.track 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 { if let track {
Group { if wide {
switch panel { HStack(spacing: 0) {
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady) panelView(track, header: false)
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() } .frame(maxWidth: .infinity, maxHeight: .infinity)
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() } .transition(.opacity.combined(with: .scale(scale: 0.96)))
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) } 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 { } 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) .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 pm = g.playback
let c = tint.map { base.tinted($0) } ?? base let c = tint.map { base.tinted($0) } ?? base
return VStack(spacing: 0) { return VStack(spacing: 0) {
if panel == .Art { if wide || panel == .Art {
HStack(alignment: .center) { HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(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))) .transition(.opacity.combined(with: .move(edge: .top)))
} }
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track)) ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, panel == .Art ? 34 : 18) .padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
HStack { HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer() Spacer()
@@ -173,10 +194,12 @@ struct NowPlayingScreen: View {
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat) SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
} }
.frame(height: 84) .frame(height: 84)
.padding(.top, 14)
HStack { HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
SpeedMenu() SpeedMenu()
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
} }
@@ -560,6 +583,9 @@ private struct MixerPanel: View {
let pm = g.playback let pm = g.playback
let names = pm.stemNames let names = pm.stemNames
let stems = pm.source == .Stems let stems = pm.source == .Stems
GeometryReader { geo in
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if pm.source == .Original { if pm.source == .Original {
@@ -599,26 +625,20 @@ private struct MixerPanel: View {
let levels = stems ? meters.step(pm.engine, names.count) : [] let levels = stems ? meters.step(pm.engine, names.count) : []
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(names.enumerated()), id: \.element) { i, name in ForEach(Array(names.enumerated()), id: \.element) { i, name in
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems) StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH)
} }
} }
} }
p.separator.frame(height: 0.5).padding(.vertical, 10) p.separator.frame(height: 0.5).padding(.vertical, 6)
HStack { FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
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)
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster) FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2) } trailing: { EmptyView() }
}
Spacer(minLength: 16) Spacer(minLength: 16)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
}
} }
} }
@@ -649,37 +669,62 @@ private struct StemFader: View {
let setting: StemSetting let setting: StemSetting
let level: Float let level: Float
let enabled: Bool let enabled: Bool
var height: CGFloat = 46
@Environment(\.palette) private var p @Environment(\.palette) private var p
private var pm: PlaybackManager { AppGraph.shared.playback } private var pm: PlaybackManager { AppGraph.shared.playback }
var body: some View { var body: some View {
let color = Color(hex: Stems.color(name)) let color = Color(hex: Stems.color(name))
VStack(alignment: .leading, spacing: 0) { FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
HStack(spacing: 8) { VStack(spacing: 0) {
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10) FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label) pm.setStemLevel(name, $0)
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) }
} }
} // Live level under the fader, -48..0 dBFS.
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) { GeometryReader { geo in
pm.setStemLevel(name, $0) let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
} ZStack(alignment: .leading) {
// Live level under the fader, -48..0 dBFS. Capsule().fill(p.fill)
GeometryReader { geo in Capsule().fill(color).frame(width: geo.size.width * f)
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
var height: CGFloat = 46
@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: height)
} }
} }
+159 -1
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron. /// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
private struct NavCell: View { private struct NavCell: View {
@@ -147,6 +148,16 @@ struct SettingsScreen: View {
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
} header: { Header(text: "Storage") } } 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 {
DebugCells()
} header: { Header(text: "Debug") } footer: {
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
}
Section { Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
} header: { Header(text: "Display") } footer: { } header: { Header(text: "Display") } footer: {
@@ -159,6 +170,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 ----------------------------------------------------------------------------------- // ---- plex -----------------------------------------------------------------------------------
struct PlexAccountScreen: View { struct PlexAccountScreen: View {
@@ -466,7 +513,7 @@ struct StorageScreen: View {
let usage = g.cache.usage let usage = g.cache.usage
List { List {
Section { Section {
Button { ui.selectTab(.Downloads) } label: { Button { ui.openDownloads() } label: {
HStack { HStack {
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes)) ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3) Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
@@ -556,6 +603,9 @@ struct AppearanceScreen: View {
.tint(Color(hex: 0x34C759)) .tint(Color(hex: 0x34C759))
.themedRow() .themedRow()
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") } } header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
Section {
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
}
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.themedList() .themedList()
@@ -563,3 +613,111 @@ struct AppearanceScreen: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
} }
// ---- tab bar editor ---------------------------------------------------------------------------
struct TabBarScreen: View {
@Environment(\.palette) private var c
private var g: AppGraph { .shared }
var body: some View {
@Bindable var s = g.settings
let shown = Tab.layout(s.tabLayout)
let hidden = Tab.allCases.filter { !shown.contains($0) }
List {
Section {
ForEach(shown, id: \.self) { t in
HStack {
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) }
}
.themedRow()
.deleteDisabled(t == .Settings)
}
.onMove { from, to in
var l = shown
l.move(fromOffsets: from, toOffset: to)
s.tabLayout = Tab.encode(l)
}
.onDelete { idx in
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
}
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
if !hidden.isEmpty {
Section {
ForEach(hidden, id: \.self) { t in
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
HStack {
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
Spacer()
Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2)
}
}
.disabled(shown.count >= Tab.maxVisible)
.themedRow()
}
} header: { Header(text: "Available") }
}
Section {
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
}
}
.environment(\.editMode, .constant(.active))
.listStyle(.insetGrouped)
.themedList()
.navigationTitle("Tab Bar")
.navigationBarTitleDisplayMode(.inline)
}
}
// ---- debug ----------------------------------------------------------------------------------
private struct DebugCells: View {
@AppStorage(Log.verboseKey) private var verbose = false
@State private var logURL: URL?
@State private var viewing = false
@Environment(\.palette) private var p
var body: some View {
Toggle(isOn: $verbose) {
HStack(spacing: 14) {
SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500)
Text("Verbose Logging").foregroundStyle(p.label)
}
}
.tint(Color(hex: 0x34C759))
.themedRow()
Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow()
if let logURL {
ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow()
} else {
Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow()
}
Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow()
.sheet(isPresented: $viewing) { LogViewer() }
}
}
private struct LogViewer: View {
@State private var text = Log.shared.text()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
Text(text.isEmpty ? "Nothing logged yet." : text)
.font(.system(size: 11, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.textSelection(.enabled)
}
.defaultScrollAnchor(.bottom)
.navigationTitle("Log")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } }
}
}
}
}
+3 -3
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.11" MARKETING_VERSION: "1.0.15"
CURRENT_PROJECT_VERSION: "12" CURRENT_PROJECT_VERSION: "16"
targets: targets:
Stemify: Stemify:
type: application type: application
@@ -32,7 +32,7 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen: {} UILaunchScreen: {}
UIBackgroundModes: [audio, fetch] UIBackgroundModes: [audio, fetch]
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
CADisableMinimumFrameDurationOnPhone: true CADisableMinimumFrameDurationOnPhone: true
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network. NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
+28 -1
View File
@@ -1,5 +1,7 @@
#!/bin/bash #!/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. # Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." 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 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
# Unsigned build, for the releases and AltStore/Sideloadly.
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload) (cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
echo "$OUT/stemify-$VERSION.ipa" 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
+36
View File
@@ -15,6 +15,42 @@
"tintColor": "#FA2D48", "tintColor": "#FA2D48",
"category": "entertainment", "category": "entertainment",
"versions": [ "versions": [
{
"version": "1.0.14",
"buildVersion": "15",
"date": "2026-10-20T14:14:26-07:00",
"localizedDescription": "Reorderable tab bar (Songs, Albums, Artists, Genres, Playlists, Search, Downloads), AirPlay route picker in Now Playing, debug logging with log export in Settings, more room above the seeker. Unsigned IPA.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.14/stemify-1.0.14.ipa",
"size": 1625201,
"minOSVersion": "17.0"
},
{
"version": "1.0.13",
"buildVersion": "14",
"date": "2026-10-20T13:42:31-07:00",
"localizedDescription": "- Export and import of stem playlists and sound settings (Settings > Backup)\n- Mixer: one-line faders, every stem fits without scrolling\n- Wide and landscape now-playing layout for iPhone Duo\n\nUnsigned IPA: re-sign with Sideloadly or AltStore.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.13/stemify-1.0.13.ipa",
"size": 1559179,
"minOSVersion": "17.0"
},
{
"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", "version": "1.0.10",
"buildVersion": "11", "buildVersion": "11",
+3
View File
@@ -43,6 +43,9 @@ curl http://192.168.86.2:8765/api/info
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` | | `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder | | `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) | | `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
| `PLEX_URL` | empty | Plex address; the web UI reaches it through `/plex/` on this server, so HTTPS pages work |
| `PLEX_TOKEN`| empty | Plex token for the web UI (set `TOKEN` too) |
| `WEB` | `1` | `0` turns off the web UI |
## API ## API
+2
View File
@@ -14,3 +14,5 @@ services:
environment: environment:
# Optional shared secret; set the same token in the app (Settings > Stem Server). # Optional shared secret; set the same token in the app (Settings > Stem Server).
- TOKEN= - TOKEN=
- PLEX_URL=
- PLEX_TOKEN=
+39
View File
@@ -16,6 +16,8 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support /file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
/plex/... proxy to PLEX_URL (needs TOKEN or the Plex token)
/api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
/ the web player (web/, or WEB_DIR); no token needed / the web player (web/, or WEB_DIR); no token needed
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song". <key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
@@ -32,7 +34,9 @@ import subprocess
import sys import sys
import threading import threading
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request
from array import array from array import array
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http import HTTPStatus from http import HTTPStatus
@@ -44,6 +48,9 @@ PORT = int(os.environ.get("PORT", "8765"))
TOKEN = os.environ.get("TOKEN", "") TOKEN = os.environ.get("TOKEN", "")
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30")) INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
PLEX_URL = os.environ.get("PLEX_URL", "").rstrip("/")
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
SERVE_WEB = os.environ.get("WEB", "1") != "0" # WEB=0: API only, no web player
MAX_BODY = 8 << 20 MAX_BODY = 8 << 20
MARKER = ".stemify.json" MARKER = ".stemify.json"
VERSION = 1 VERSION = 1
@@ -259,6 +266,31 @@ class Handler(BaseHTTPRequestHandler):
return True return True
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0]) return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
def _plex_proxy(self, path, rawquery, query):
"""Forward to PLEX_URL so an HTTPS page can reach an HTTP Plex. Needs the stem or Plex token."""
given = self.headers.get("X-Plex-Token") or query.get("X-Plex-Token", [None])[0]
if not (self._authorized(query) or (PLEX_TOKEN and given == PLEX_TOKEN)):
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
pairs = [(k, v) for k, v in urllib.parse.parse_qsl(rawquery, keep_blank_values=True) if k not in ("token", "X-Plex-Token")]
req = urllib.request.Request(PLEX_URL + path + ("?" + urllib.parse.urlencode(pairs) if pairs else ""))
req.add_header("X-Plex-Token", PLEX_TOKEN or given or "")
for h in ("Range", "Accept", "X-Plex-Client-Identifier", "X-Plex-Product", "If-None-Match"):
if self.headers.get(h):
req.add_header(h, self.headers[h])
try:
r = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e:
r = e
except OSError as e:
return self._error(502, f"plex unreachable: {e}")
self.send_response(r.status if hasattr(r, "status") else r.code)
for h in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"):
if r.headers.get(h):
self.send_header(h, r.headers[h])
self.end_headers()
while chunk := r.read(65536):
self.wfile.write(chunk)
def _send(self, status, body=b"", ctype="application/json", headers=None): def _send(self, status, body=b"", ctype="application/json", headers=None):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
@@ -273,6 +305,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(status, json.dumps({"error": msg}).encode()) self._send(status, json.dumps({"error": msg}).encode())
def _web(self, path): def _web(self, path):
if not SERVE_WEB:
return self._error(404, "not found")
if path == "/": if path == "/":
path = "/index.html" path = "/index.html"
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/"))) full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
@@ -293,6 +327,8 @@ class Handler(BaseHTTPRequestHandler):
url = urllib.parse.urlsplit(self.path) url = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(url.query) query = urllib.parse.parse_qs(url.query)
path = urllib.parse.unquote(url.path) path = urllib.parse.unquote(url.path)
if path.startswith("/plex/") and PLEX_URL:
return self._plex_proxy(path[len("/plex"):], url.query, query)
if not path.startswith(("/api/", "/file/")): if not path.startswith(("/api/", "/file/")):
return self._web(path) return self._web(path)
if not self._authorized(query): if not self._authorized(query):
@@ -304,6 +340,9 @@ class Handler(BaseHTTPRequestHandler):
"name": "stemserve", "version": VERSION, "tracks": INDEX.count, "name": "stemserve", "version": VERSION, "tracks": INDEX.count,
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [], "ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode()) "stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
if path == "/api/config":
plex = {"url": PLEX_URL, "token": PLEX_TOKEN} if PLEX_URL and PLEX_TOKEN else None
return self._send(200, json.dumps({"plex": plex}).encode(), headers={"Cache-Control": "no-store"})
if path == "/api/index": if path == "/api/index":
body, etag = INDEX.get() body, etag = INDEX.get()
if self.headers.get("If-None-Match") == etag: if self.headers.get("If-None-Match") == etag:
+25 -3
View File
@@ -60,7 +60,7 @@ const plexHeaders = token => ({
async function px(path, params = {}) { async function px(path, params = {}) {
if (!S.plex) throw new Error("Not signed in to Plex"); if (!S.plex) throw new Error("Not signed in to Plex");
const u = new URL(S.plex.url + path); const u = new URL(S.plex.url + path, location.href);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: plexHeaders(S.plex.token) }); const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
if (!r.ok) throw new Error(`Plex ${r.status}`); if (!r.ok) throw new Error(`Plex ${r.status}`);
@@ -782,7 +782,7 @@ const routes = {
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist"); if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1> view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form"> <h2>Plex</h2><div class="form">
<p class="note">${S.plex ? `Connected to <b>${esc(S.plex.name || S.plex.url)}</b> at ${esc(S.plex.url)}` : "Not signed in."}</p> <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> <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>` : ""} ${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"> <details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
@@ -835,10 +835,32 @@ $("#search").onsubmit = ev => {
if (q) location.hash = "#/search/" + encodeURIComponent(q); 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 ----------------------------------------------------------------------------- // ---- start -----------------------------------------------------------------------------
(async () => { (async () => {
await Promise.race([Stems.refresh(), new Promise(r => setTimeout(r, 4000))]); await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
const saved = load(KEY + "-queue"); const saved = load(KEY + "-queue");
if (saved?.items?.length) { if (saved?.items?.length) {
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1); Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);