Author SHA1 Message Date
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 715 additions and 63 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
+9
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.
@@ -132,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>
+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 = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"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 {
@@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable {
/// Plex path prefixes that matched the stems index, newline-separated.
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 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) } }
+1 -1
View File
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
}
} catch {
if !released {
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
state.with { _error = error }
}
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).
func report(_ text: String) {
Log.shared.error("report", text)
message = AppMessage(text: text)
messageListeners.values.forEach { $0(text) }
}
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let self, let info = n.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
switch type {
case .began:
// 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
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
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.
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
self.eq.refreshRoute()
}
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
guard let self else { return }
Log.shared.error("session", "media services reset")
let was = self.playWhenReady
let pos = self.positionMs()
self.currentToken = -1
+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])
+20
View File
@@ -1,4 +1,6 @@
import AVKit
import SwiftUI
import UIKit
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
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 {
switch self {
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 .Search: "magnifyingglass"
case .Downloads: "arrow.down.circle.fill"
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, 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 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.
@@ -47,13 +68,30 @@ final class Ui {
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 }
/// Switches to the Library tab and pushes [r] there.
func showInLibrary(_ r: Route) {
nowPlayingOpen = false
tab = .Library
paths[.Library, default: []].append(r)
let t = home
tab = t
paths[t, default: []].append(r)
}
/// 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 p = Palette.of(s.theme, s.accent)
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)) {
root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
@@ -20,6 +20,7 @@ struct RootView: View {
}
}
.modifier(GlassMiniPlayerHost())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen()
.overlayHost(ui.overlays, level: 1)
@@ -41,6 +42,10 @@ struct RootView: View {
@ViewBuilder private func root(_ t: Tab) -> some View {
switch t {
case .Library: LibraryHome()
case .Songs: SongsScreen()
case .Albums: AlbumsScreen()
case .Artists: ArtistsScreen()
case .Genres: GenresScreen()
case .Playlists: PlaylistsScreen()
case .Search: SearchScreen()
case .Downloads: DownloadsScreen()
@@ -54,6 +59,10 @@ struct RootView: View {
case .Albums: AlbumsScreen()
case .Songs: SongsScreen()
case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen()
case .Downloads: DownloadsScreen()
case .TabBar: TabBarScreen()
case .GenrePage(let g): GenreScreen(genre: g)
case .Eq: EqScreen()
case .EqFilters(let id): EqFiltersScreen(profileId: id)
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") }
}
}
}
+86 -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 : 30)
HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer()
@@ -173,10 +194,12 @@ 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()
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: "list.bullet", on: panel == .Queue) { show(.Queue) }
}
@@ -603,18 +626,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 +670,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)
}
}
+159 -1
View File
@@ -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,16 @@ 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 {
DebugCells()
} header: { Header(text: "Debug") } footer: {
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
}
Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
} 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 -----------------------------------------------------------------------------------
struct PlexAccountScreen: View {
@@ -466,7 +513,7 @@ struct StorageScreen: View {
let usage = g.cache.usage
List {
Section {
Button { ui.selectTab(.Downloads) } label: {
Button { ui.openDownloads() } label: {
HStack {
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
@@ -556,6 +603,9 @@ struct AppearanceScreen: View {
.tint(Color(hex: 0x34C759))
.themedRow()
} 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)
.themedList()
@@ -563,3 +613,111 @@ struct AppearanceScreen: View {
.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:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.11"
CURRENT_PROJECT_VERSION: "12"
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.
+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.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",
"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=` |
| `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
+2
View File
@@ -14,3 +14,5 @@ services:
environment:
# Optional shared secret; set the same token in the app (Settings > Stem Server).
- 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/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".
@@ -32,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
@@ -44,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
@@ -259,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)
@@ -273,6 +305,8 @@ class Handler(BaseHTTPRequestHandler):
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("/")))
@@ -293,6 +327,8 @@ 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):
@@ -304,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:
+25 -3
View File
@@ -60,7 +60,7 @@ const plexHeaders = 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);
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}`);
@@ -782,7 +782,7 @@ const routes = {
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 ? `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>
${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">
@@ -835,10 +835,32 @@ $("#search").onsubmit = ev => {
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([Stems.refresh(), new Promise(r => setTimeout(r, 4000))]);
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);