17 Commits
Author SHA1 Message Date
rootandClaude Opus 5.5 02f8452be2 publish.sh: dev uploads go to the main admin with ?source=developer (dev-admin :8097 is gone)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:24:53 -08:00
rootandClaude Opus 5.5 567e557968 iOS: show stem level inside the fader track
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:30:45 -08:00
rootandClaude Opus 5.5 ae5eead73b iOS: drop Master fader, roomier static Duo stem rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:36:52 -08:00
rootandClaude Opus 5.5 5a7a36b898 iOS: same display name for every build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:03:49 -08:00
rootandClaude Opus 5.5 f9933a4883 iOS: prov uses the main bundle ID, release becomes -pub
prov    = org.cdnutter.<name> (personal profile)
release = org.cdnutter.<name>-pub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:25:56 -08:00
rootandClaude Opus 5.5 ec1cbdfe75 iOS: release/dev/prov builds, publish.sh, dev icon variants
release = org.cdnutter.<name> (main, Gitea release + Sideload)
dev     = org.cdnutter.<name>-dev (dev branch, Gitea pre-release + Sideload Developer)
prov    = org.cdnutter.<name>-prov (personal signed, never published)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:18:32 -08:00
rootandClaude Opus 5.5 fabca5d1aa iOS: Duo layout fixes, swipe-up option, fuller backup restore
- DuoRail sits at the bottom of the strip, clear of the back buttons
- Setting to turn off swipe up for Playing Next
- Tighter Now Playing on iPhone Duo (folded and docked), regular phones unchanged
- Bigger stacked stem faders in the Duo deck
- Backup v2 restores song mixes, custom presets and stem playlist covers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:07:22 -08:00
rootandClaude Opus 5.5 e8bc920454 iOS: default build is dev + prov, public only on request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:09:10 -08:00
rootandClaude Opus 5.5 08f7af9421 iOS: Duo unfolded layout with right-edge tab rail, stems+queue deck and swap sides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:02:08 -08:00
rootandClaude Opus 5.5 f4af7b6cf8 iOS: dev builds get a 'dev ♡' sash on the app icon (AppIconDev)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:07:13 -08:00
rootandClaude Opus 5.5 d996d3e236 iOS: dev build variant (org.cdnutter.stemify-dev) for internal tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:00:54 -08:00
rootandClaude Opus 5.5 4f2dcb94f5 iOS: Duo split engages when flat (include inactive hinge), size fallback; 1.0 RC 4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:20:17 -08:00
rootandClaude Opus 5.5 dfdd9a8ae5 iOS: source.json for 1.0 RC 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:01:30 -08:00
rootandClaude Opus 5.5 db55b5eea0 iOS: iPhone Duo split layout at the hinge, 1.0 RC 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:58 -08:00
cdnutterandClaude Sonnet 5.5 cf5f992e60 stemserve: refresh index in a background thread
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 14:17:38 -08:00
rootandClaude Opus 5.5 f099682a88 ios: build-remote defaults to white-m1 host
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:33:45 -08:00
rootandClaude Opus 5.5 9415bfb50f iOS: source.json for 1.0 RC 2 rebuild
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:55:29 -08:00
23 changed files with 581 additions and 132 deletions

No files matched your search

+5
View File
@@ -28,3 +28,8 @@
2026-11-06 iOS: stem playlist covers can come from Photos (Change Cover > Choose from Photos). Stored on the phone, 1200 px JPEG. 2026-11-06 iOS: stem playlist covers can come from Photos (Change Cover > Choose from Photos). Stored on the phone, 1200 px JPEG.
2026-11-06 iOS: Update Download on downloaded playlists, albums, artists and stem playlists (their menus and the Downloads tab), plus Update All Downloads. Pulls the current song list, downloads new songs, drops removed ones, retries failed ones. Downloads still sync on their own when the app opens. 2026-11-06 iOS: Update Download on downloaded playlists, albums, artists and stem playlists (their menus and the Downloads tab), plus Update All Downloads. Pulls the current song list, downloads new songs, drops removed ones, retries failed ones. Downloads still sync on their own when the app opens.
2026-11-06 iOS: crossfade, Off or 1-12 s in Settings > Listening. Equal-power fade, Sweet Fades skips it between songs of the same album. Seeking during a fade finishes it. 2026-11-06 iOS: crossfade, Off or 1-12 s in Settings > Listening. Equal-power fade, Sweet Fades skips it between songs of the same album. Seeking during a fade finishes it.
2026-11-09 iOS: iPhone Duo unfolded uses both sides of the hinge: library on one side, Now Playing docked on the other (no mini player while split). Folded and other iPhones unchanged. 1.0 RC 3 (build 23).
2026-11-09 iOS: Duo split now engages with the phone lying flat (hinge region reports inactive then), with a screen-size fallback if no region is reported. 1.0 RC 4 (build 24).
2026-11-09 iOS: dev builds (`ios/scripts/build-remote.sh dev`): bundle id org.cdnutter.stemify-dev, "Stemify Dev", own App Group, into ios/build/dev. Internal tests only, never on Gitea or Sideload.
2026-11-09 iOS: Duo unfolded redesign. Tabs move to a rail on the right edge by the status bar, Now Playing fills the screen with stems and queue on the left and the player on the right, picking a tab swaps the left pane, Swap flips the sides (remembered). Folded screen unchanged.
2026-11-09 iOS: build-remote.sh now builds dev + prov by default; public only with `public`.
+11 -3
View File
@@ -338,12 +338,20 @@ docs/specs.txt original design doc for the desktop separator
```sh ```sh
cd ios cd ios
scripts/build-ipa.sh # unsigned build/public/stemify-<version>.ipa scripts/build-remote.sh # from DevOps: dev build on WHITE-M1, pulled back to build/dev
scripts/build-remote.sh # from DevOps: build on White-M1 and pull IPAs back to build/{public,prov} scripts/build-remote.sh release # release build, build/release
scripts/build-remote.sh prov # personal signed build, build/prov
scripts/build-ipa.sh release|dev|prov # same thing directly on the Mac
xcodegen generate # or open Stemify.xcodeproj in Xcode xcodegen generate # or open Stemify.xcodeproj in Xcode
``` ```
The version lives in `ios/project.yml` (`MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`). To release, tag `ios-v<version>`, attach `stemify-<version>.ipa`, then run `GITEA_TOKEN=... ios/scripts/make-source.py` and commit the new `ios/source.json`. There are three builds, each with its own bundle ID so they install side by side:
- `org.cdnutter.stemify-pub` (build/release): releases from `main`. `scripts/publish.sh release` tags `ios-v<version>`, uploads the IPA to Gitea and Sideload, and regenerates `ios/source.json`.
- `org.cdnutter.stemify-dev` (build/dev): ribbon icon, built from the `dev` branch. `scripts/publish.sh dev` makes a Gitea pre-release `ios-v<version>-dev.<build>` and uploads it to Sideload Developer. Bump `CURRENT_PROJECT_VERSION` for each one.
- `org.cdnutter.stemify` (build/prov): signed with the personal profile in `ios/prov/` so CarPlay and Siri work. Never published.
The version lives in `ios/project.yml` (`VERSION_LABEL`, `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`).
**Android** needs JDK 21 and the Android SDK (`android/local.properties` points at it): **Android** needs JDK 21 and the Android SDK (`android/local.properties` points at it):
+1 -1
View File
@@ -33,7 +33,7 @@ struct WidgetSnapshot: Codable, Equatable {
} }
enum WidgetStore { enum WidgetStore {
static let group = "group.org.cdnutter.stemify" static let group = Bundle.main.object(forInfoDictionaryKey: "StemifyAppGroup") as? String ?? "group.org.cdnutter.stemify"
static let mixerKind = "org.cdnutter.stemify.mixer" static let mixerKind = "org.cdnutter.stemify.mixer"
static let mixKind = "org.cdnutter.stemify.mix" static let mixKind = "org.cdnutter.stemify.mix"
static let controlKind = "org.cdnutter.stemify.mix-control" static let controlKind = "org.cdnutter.stemify.mix-control"
+52 -7
View File
@@ -2,11 +2,13 @@ import Foundation
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings. /// What an export file holds: stem playlists (with each song's mix and any cover picked from
/// Plex and stem server tokens and URLs are never included. /// Photos), each song's saved mix, custom presets and the preset bar, and the non-secret sound
/// settings. Plex and stem server tokens and URLs are never included.
struct StemifyExport: Codable { struct StemifyExport: Codable {
static let format = "stemify-export" static let format = "stemify-export"
static let version = 1 /// 2 added song mixes, presets and playlist covers. Version 1 files still import.
static let version = 2
struct Prefs: Codable { struct Prefs: Codable {
var stemQuality: String? var stemQuality: String?
@@ -15,6 +17,10 @@ struct StemifyExport: Codable {
var karaokeSoftDb: Int? var karaokeSoftDb: Int?
var playbackSpeed: Double? var playbackSpeed: Double?
var downloadsWifiOnly: Bool? var downloadsWifiOnly: Bool?
var presets: String?
var presetOrder: String?
var hiddenPresets: String?
var lockedPreset: String?
} }
var format = StemifyExport.format var format = StemifyExport.format
@@ -22,12 +28,23 @@ struct StemifyExport: Codable {
var exported = Date() var exported = Date()
var playlists: [StemPlaylist] var playlists: [StemPlaylist]
var prefs: Prefs var prefs: Prefs
/// Saved per-song mixes, by rating key.
var songMixes: [String: [String: StemSetting]]?
/// Covers picked from Photos ("local:<file>"), base64 by file name.
var playlistArt: [String: String]?
@MainActor @MainActor
static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport { static func capture(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> StemifyExport {
StemifyExport(playlists: lists.lists, prefs: Prefs( var art: [String: String] = [:]
for pl in lists.lists {
guard let t = pl.artThumb, let u = StemPlaylists.localArt(t), let d = try? Data(contentsOf: u) else { continue }
art[u.lastPathComponent] = d.base64EncodedString()
}
return StemifyExport(playlists: lists.lists, prefs: Prefs(
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals, stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly)) karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly,
presets: s.presets, presetOrder: s.presetOrder, hiddenPresets: s.hiddenPresets, lockedPreset: s.lockedPreset),
songMixes: pm.songMixes, playlistArt: art)
} }
func encoded() throws -> Data { func encoded() throws -> Data {
@@ -46,14 +63,42 @@ struct StemifyExport: Codable {
return x return x
} }
/// Restores everything: playlists and song mixes from the file replace the ones with the same
/// id or song, presets merge by name, and the rest is left alone.
@MainActor @MainActor
func apply(to s: Settings) { func restore(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> (playlists: Int, mixes: Int) {
if let art = playlistArt, !art.isEmpty {
let dir = StemPlaylists.artDir
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
for (name, b64) in art where !name.isEmpty && !name.contains("/") {
if let d = Data(base64Encoded: b64) { try? d.write(to: dir.appendingPathComponent(name), options: .atomic) }
}
}
let n = lists.restore(playlists)
let m = songMixes ?? [:]
pm.importSongMixes(m)
apply(to: s)
return (n, m.count)
}
@MainActor
private func apply(to s: Settings) {
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v } if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
if let v = prefs.keepStemMix { s.keepStemMix = v } if let v = prefs.keepStemMix { s.keepStemMix = v }
if let v = prefs.karaokeVocals { s.karaokeVocals = v } if let v = prefs.karaokeVocals { s.karaokeVocals = v }
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v } if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
if let v = prefs.playbackSpeed { s.playbackSpeed = v } if let v = prefs.playbackSpeed { s.playbackSpeed = v }
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v } if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
if let v = prefs.presets {
// Merge by name: the file's presets win, ones only on this phone stay.
let incoming = (v.data(using: .utf8)).flatMap { try? JSONDecoder().decode([CustomPreset].self, from: $0) } ?? []
let names = Set(incoming.map(\.name))
let merged = CustomPreset.load(s).filter { !names.contains($0.name) } + incoming
if let d = try? JSONEncoder().encode(merged), let str = String(data: d, encoding: .utf8) { s.presets = str }
}
if let v = prefs.presetOrder, !v.isEmpty { s.presetOrder = v }
if let v = prefs.hiddenPresets { s.hiddenPresets = v }
if let v = prefs.lockedPreset { s.lockedPreset = v }
} }
} }
+3 -1
View File
@@ -109,7 +109,7 @@ final class Settings: @unchecked Sendable {
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella", "crossfadeSec", "sweetFades", "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "swipeUpQueue", "acapella", "crossfadeSec", "sweetFades",
] ]
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 {
@@ -216,6 +216,8 @@ final class Settings: @unchecked Sendable {
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) } }
/// Spells the A Cappella mix "Acapella". MixPresets reads the key directly. /// Spells the A Cappella mix "Acapella". MixPresets reads the key directly.
var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } } var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } }
/// Swipe up on Now Playing opens Playing Next.
var swipeUpQueue: Bool { get { read(\.swipeUpQueue, "swipeUpQueue", true) } set { write(\.swipeUpQueue, "swipeUpQueue", newValue, true) } }
var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } } var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } }
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) } }
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } } var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
@@ -592,9 +592,22 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) } func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
func forgetSongMix() { setSongMix(nil) } func forgetSongMix() { setSongMix(nil) }
/// From a backup: its saved mixes replace this phone's for the same songs.
func importSongMixes(_ m: [String: [String: StemSetting]]) {
guard !m.isEmpty else { return }
songMixes.merge(m) { _, new in new }
saveSongMixes()
// The playing song picks up its restored mix right away.
if let s = songMix { applyMix(s) }
}
private func setSongMix(_ m: [String: StemSetting]?) { private func setSongMix(_ m: [String: StemSetting]?) {
guard let key = current?.track.ratingKey else { return } guard let key = current?.track.ratingKey else { return }
songMixes[key] = m songMixes[key] = m
saveSongMixes()
}
private func saveSongMixes() {
let all = songMixes, file = songMixFile let all = songMixes, file = songMixFile
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
+5 -13
View File
@@ -163,22 +163,14 @@ 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 from a backup: the backup's copy of each list wins, lists only on this phone stay.
/// import was updated more recently. Returns how many lists were added or replaced.
@discardableResult @discardableResult
func merge(_ incoming: [StemPlaylist]) -> Int { func restore(_ incoming: [StemPlaylist]) -> Int {
var n = 0
for pl in incoming { for pl in incoming {
if let i = lists.firstIndex(where: { $0.id == pl.id }) { if let i = lists.firstIndex(where: { $0.id == pl.id }) { lists[i] = pl } else { lists.append(pl) }
guard pl.updated > lists[i].updated else { continue }
lists[i] = pl
} else {
lists.append(pl)
} }
n += 1 if !incoming.isEmpty { save() }
} return incoming.count
if n > 0 { save() }
return n
} }
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) { private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "icon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" },
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon-dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" },
{ "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "icon-tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+22 -1
View File
@@ -51,6 +51,10 @@ final class Ui {
var nowPlayingOpen = false { var nowPlayingOpen = false {
didSet { overlays.level = nowPlayingOpen ? 1 : 0 } didSet { overlays.level = nowPlayingOpen ? 1 : 0 }
} }
/// True while an unfolded iPhone Duo shows Now Playing docked beside the library.
private(set) var split = false
/// Split only: the side opposite Now Playing shows the stems and queue (true) or the selected tab.
var deck = true
let overlays: Overlays let overlays: Overlays
init(overlays: Overlays) { self.overlays = overlays } init(overlays: Overlays) { self.overlays = overlays }
@@ -65,6 +69,7 @@ final class Ui {
/// Tapping the selected tab again pops it to its root. /// Tapping the selected tab again pops it to its root.
func selectTab(_ t: Tab) { func selectTab(_ t: Tab) {
if split && deck { deck = false; tab = t; return }
if t == tab { paths[t] = [] } else { tab = t } if t == tab { paths[t] = [] } else { tab = t }
} }
@@ -84,11 +89,26 @@ final class Ui {
if !tabs.contains(tab) { tab = tabs.first ?? .Settings } if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
} }
func openNowPlaying() { nowPlayingOpen = true } /// Docked Now Playing is already on screen, so there's nothing to open while split.
func openNowPlaying() { if !split { nowPlayingOpen = true } }
/// Split only: brings the stems and queue back beside Now Playing.
func showDeck() { deck = true }
func setSplit(_ on: Bool) {
guard on != split else { return }
split = on
if on {
nowPlayingOpen = false
// Only an iPhone Duo ever splits, so from here on folded Now Playing uses its tighter layout.
UserDefaults.standard.set(true, forKey: "duoSeen")
}
}
/// 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
deck = false
let t = home let t = home
tab = t tab = t
paths[t, default: []].append(r) paths[t, default: []].append(r)
@@ -97,6 +117,7 @@ final class Ui {
/// Switches to Settings and opens [r] (the Equalizer) there. /// Switches to Settings and opens [r] (the Equalizer) there.
func showInSettings(_ r: Route) { func showInSettings(_ r: Route) {
nowPlayingOpen = false nowPlayingOpen = false
deck = false
tab = .Settings tab = .Settings
if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) } if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) }
} }
+141 -3
View File
@@ -1,13 +1,19 @@
import SwiftUI import SwiftUI
import UIKit
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet. /// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
/// On an unfolded iPhone Duo Now Playing takes one side of the hinge, the stems and queue (or a tab)
/// the other, and the tabs move to a rail on the right edge beside the status bar.
struct RootView: View { struct RootView: View {
@State private var ui = Ui(overlays: Overlays()) @State private var ui = Ui(overlays: Overlays())
/// Unfolded iPhone Duo: Now Playing on the left of the hinge instead of the right.
@AppStorage("duoSwap") private var duoSwap = false
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some 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)
DuoSplit(swap: duoSwap, showDeck: ui.deck, onSplit: { ui.setSplit($0) }) {
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) { TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
NavigationStack(path: ui.path(t)) { NavigationStack(path: ui.path(t)) {
@@ -20,6 +26,14 @@ struct RootView: View {
} }
} }
.modifier(GlassMiniPlayerHost()) .modifier(GlassMiniPlayerHost())
} deck: {
NowPlayingDeck()
} docked: {
NowPlayingScreen(docked: true)
} rail: {
DuoRail(swap: $duoSwap)
}
.background(p.bg.ignoresSafeArea())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() } .onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { .fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen() NowPlayingScreen()
@@ -91,11 +105,12 @@ struct RootView: View {
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }() private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
private struct GlassMiniPlayerHost: ViewModifier { private struct GlassMiniPlayerHost: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26.1, *) { if #available(iOS 26.1, *) {
content content
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() } .tabViewBottomAccessory(isEnabled: g.playback.current != nil && !ui.split) { MiniPlayer() }
.tabBarMinimizeBehavior(.onScrollDown) .tabBarMinimizeBehavior(.onScrollDown)
} else { } else {
content content
@@ -105,9 +120,10 @@ private struct GlassMiniPlayerHost: ViewModifier {
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath. /// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
private struct FloatingMiniPlayer: ViewModifier { private struct FloatingMiniPlayer: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
func body(content: Content) -> some View { func body(content: Content) -> some View {
if glassAccessory { if glassAccessory || ui.split {
content content
} else { } else {
content content
@@ -120,14 +136,136 @@ private struct FloatingMiniPlayer: ViewModifier {
} }
private struct MiniPlayerRoom: ViewModifier { private struct MiniPlayerRoom: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) { content.safeAreaInset(edge: .bottom, spacing: 0) {
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) } if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) }
} }
// Split, the rail stands in for the tab bar.
.toolbar(ui.split ? .hidden : .automatic, for: .tabBar)
} }
} }
private extension View { private extension View {
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) } func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
} }
/// Splits the screen at the iPhone Duo's hinge (the system's division region). [library] (with
/// [deck] over it while [showDeck]) goes on one side and [docked] on the other, left and right
/// trading places with [swap]; [rail] runs down the right edge in the status bar's strip.
/// Anywhere without a hinge it's just [library]. The library stays put in the view tree, so its
/// tab and scroll state survive folding, unfolding and swapping.
private struct DuoSplit<Library: View, Deck: View, Docked: View, Rail: View>: View {
var swap: Bool
var showDeck: Bool
var onSplit: (Bool) -> Void
@ViewBuilder var library: Library
@ViewBuilder var deck: Deck
@ViewBuilder var docked: Docked
@ViewBuilder var rail: Rail
private static var railWidth: CGFloat { 64 }
var body: some View {
GeometryReader { geo in
let cut = Self.cut(geo)
let inset = geo.safeAreaInsets.trailing
// The rail lives in the status bar's strip; if that's too thin it borrows from the right pane.
let steal = cut == nil ? 0 : max(0, Self.railWidth - inset)
let flip = swap && cut?.vertical == true
let layout = cut?.vertical == false ? AnyLayout(VStackLayout(spacing: cut?.gap ?? 0)) : AnyLayout(HStackLayout(spacing: cut?.gap ?? 0))
layout {
ZStack {
library
if cut != nil && showDeck { deck.transition(.opacity) }
}
.environment(\.layoutDirection, .leftToRight)
.frame(width: cut.map { $0.vertical ? (flip ? $0.second - steal : $0.first) : geo.size.width - steal } ?? geo.size.width,
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height)
if let cut {
docked
.environment(\.layoutDirection, .leftToRight)
.frame(width: cut.vertical ? (flip ? cut.first : cut.second - steal) : geo.size.width - steal,
height: cut.vertical ? geo.size.height : cut.second)
}
}
// Right-to-left lays the same two children out mirrored, so swapping doesn't rebuild either.
.environment(\.layoutDirection, flip ? .rightToLeft : .leftToRight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .topTrailing) {
if cut != nil {
rail.frame(width: max(inset, Self.railWidth), height: geo.size.height).offset(x: inset)
}
}
.animation(.spring(duration: 0.4, bounce: 0.1), value: flip)
.animation(.easeInOut(duration: 0.2), value: showDeck)
.onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
}
}
/// Where the hinge cuts the view: sizes of the two sides and the gap between them.
private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat }
private static func cut(_ geo: GeometryProxy) -> Cut? {
let size = geo.size
var c: Cut?
// The hinge reports inactive while the phone lies flat, so ask for inactive regions too.
if #available(iOS 27.1, *),
let r = geo.reservedRegions(kind: .division, options: .includeInactive).first?.frame {
let vertical = r.height >= r.width
c = vertical
? Cut(vertical: true, first: r.minX, gap: r.width, second: size.width - r.maxX)
: Cut(vertical: false, first: r.minY, gap: r.height, second: size.height - r.maxY)
} else if UIDevice.current.userInterfaceIdiom == .phone, size.width >= 760, size.height >= 560 {
// No region reported: only an unfolded Duo gives an iPhone this much room both ways.
// The hinge sits at the middle of the screen, not of the safe area, so place it from there.
let screenW = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.bounds.width
let half = ((screenW ?? size.width) / 2 - geo.frame(in: .global).minX).rounded()
c = Cut(vertical: true, first: half - 12, gap: 24, second: size.width - half - 12)
}
guard let c else { return nil }
// A region that doesn't leave usable room on both sides isn't a hinge worth splitting at.
return c.first > 200 && c.second > 200 ? c : nil
}
}
/// The tab bar's stand-in on an unfolded iPhone Duo: Now Playing's stems and queue, the tabs, and
/// a button that swaps which side of the hinge Now Playing sits on.
private struct DuoRail: View {
@Binding var swap: Bool
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
var body: some View {
// Sits at the bottom of the strip: the top is where the panes' back buttons and the
// status bar live, and the tabs used to land right on them.
VStack(spacing: 4) {
Spacer(minLength: 160)
item("Playing", "waveform", on: ui.deck) { ui.showDeck() }
ForEach(ui.tabs, id: \.self) { t in
item(t.rawValue, t.icon, on: !ui.deck && ui.tab == t) { ui.selectTab(t) }
}
Capsule().fill(p.label3.opacity(0.4)).frame(width: 24, height: 2).padding(.vertical, 8)
item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() }
}
.padding(.bottom, 20)
.frame(maxWidth: .infinity)
}
private func item(_ title: String, _ icon: String, on: Bool, _ action: @escaping () -> Void) -> some View {
Button { withAnimation(.snappy(duration: 0.25)) { action() } } label: {
VStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 19, weight: on ? .semibold : .regular)).frame(height: 24)
Text(title).font(.system(size: 10, weight: .medium)).lineLimit(1).minimumScaleFactor(0.7)
}
.foregroundStyle(on ? p.accent : p.label2)
.frame(width: 56, height: 52)
.background(on ? p.accent.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(PressFade())
.accessibilityLabel(title)
.accessibilityAddTraits(on ? .isSelected : [])
}
}
@@ -50,6 +50,18 @@ struct NowPlayingOptionsScreen: View {
.themedRow() .themedRow()
} }
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) } } footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
Section {
Toggle(isOn: $s.swipeUpQueue) {
HStack(spacing: 14) {
SettingsIcon(symbol: "hand.draw", bg: 0x5856D6)
Text("Swipe Up for Playing Next").foregroundStyle(p.label)
}
}
.tint(Color(hex: 0x34C759))
.themedRow()
} header: { Text("Gestures").foregroundStyle(p.label2) } footer: {
Text("Off, swiping up on the art or the stems does nothing. Tap Playing Next under the controls instead.").foregroundStyle(p.label2)
}
Section { Section {
Toggle(isOn: $s.lockScreenMix) { Toggle(isOn: $s.lockScreenMix) {
HStack(spacing: 14) { HStack(spacing: 14) {
+149 -52
View File
@@ -86,7 +86,21 @@ private extension UIColor {
// ---- now playing ---------------------------------------------------------------------------- // ---- now playing ----------------------------------------------------------------------------
/// Full screen, Now Playing owns the window's color scheme. Docked beside the library it only
/// sets its own pane's, since preferredColorScheme would repaint the library side too.
private struct NpScheme: ViewModifier {
var dark: Bool
var docked: Bool
func body(content: Content) -> some View {
if docked { content.environment(\.colorScheme, dark ? .dark : .light) }
else { content.preferredColorScheme(dark ? .dark : .light) }
}
}
struct NowPlayingScreen: View { struct NowPlayingScreen: View {
/// Shown beside the library on an unfolded iPhone Duo rather than as a full-screen cover:
/// no grabber, no drag to close, and its color scheme stays inside the pane.
var docked = false
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@Environment(\.palette) private var base @Environment(\.palette) private var base
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -94,6 +108,10 @@ struct NowPlayingScreen: View {
@State private var tint: Color? @State private var tint: Color?
/// How far the screen is being dragged down to close it. /// How far the screen is being dragged down to close it.
@State private var drop: CGFloat = 0 @State private var drop: CGFloat = 0
/// Set once the phone has unfolded, so it's an iPhone Duo: its screens are shorter for their
/// width than a regular iPhone's, so the controls pack tighter and the art gets the room.
@AppStorage("duoSeen") private var duoSeen = false
private var tight: Bool { docked || duoSeen }
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some View {
@@ -119,7 +137,7 @@ struct NowPlayingScreen: View {
panelView(track, header: true) panelView(track, header: true)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96))) .transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track, wide: false).frame(maxWidth: 560) controls(track, wide: false).frame(maxWidth: tight ? 520 : 560)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@@ -128,23 +146,23 @@ struct NowPlayingScreen: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
.padding(.top, 20) .padding(.top, docked ? 4 : tight ? 14 : 20)
.padding(.bottom, 8) .padding(.bottom, tight ? 4 : 8)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea()) .background(c.bg.ignoresSafeArea())
.overlay(alignment: .top) { .overlay(alignment: .top) {
// Stands in for the sheet's grabber: tap or drag down to close. // Stands in for the sheet's grabber: tap or drag down to close.
Capsule().fill(c.label3).frame(width: 36, height: 5) if !docked { Capsule().fill(c.label3).frame(width: 36, height: 5)
.frame(width: 120, height: 20).contentShape(Rectangle()) .frame(width: 120, height: 20).contentShape(Rectangle())
.onTapGesture { dismiss() } .onTapGesture { dismiss() }
.gesture(screenDrag) .gesture(screenDrag) }
} }
.offset(y: drop) .offset(y: drop)
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the // On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
// queue. The queue and lyrics scroll, so there it's the song header or the grabber. // queue. The queue and lyrics scroll, so there it's the song header or the grabber.
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews) .simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
.environment(\.palette, c) .environment(\.palette, c)
.preferredColorScheme(c.dark ? .dark : .light) .modifier(NpScheme(dark: c.dark, docked: docked))
.tint(c.label) .tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint) .animation(.easeInOut(duration: 0.3), value: tint)
.presentationBackground(.clear) .presentationBackground(.clear)
@@ -158,7 +176,7 @@ struct NowPlayingScreen: View {
@ViewBuilder @ViewBuilder
private func panelView(_ track: Track, header: Bool) -> some View { private func panelView(_ track: Track, header: Bool) -> some View {
switch panel { switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle()) case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady, inset: tight ? 18 : 28).contentShape(Rectangle())
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() } case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) } case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) } case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
@@ -181,11 +199,11 @@ struct NowPlayingScreen: View {
Spacer(minLength: 8) Spacer(minLength: 8)
EllipsisButton(items: ui.menu.track(track)) EllipsisButton(items: ui.menu.track(track))
} }
.padding(.top, 14) .padding(.top, tight ? 8 : 14)
.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, wide ? 24 : panel == .Art ? 34 : 30) .padding(.top, tight ? (wide ? 14 : 18) : 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()
@@ -206,29 +224,29 @@ struct NowPlayingScreen: View {
Spacer() Spacer()
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: tight ? 76 : 84)
.padding(.top, 14) .padding(.top, tight ? 4 : 14)
HStack { HStack {
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying) let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } } if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : panel == .Mixer) { show(.Mixer) } }
if !hidden.contains(.Speed) { SpeedMenu() } if !hidden.contains(.Speed) { SpeedMenu() }
if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() } if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() }
if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) } if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) }
if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } } if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } }
} }
.frame(height: 48) .frame(height: 48)
// Swipe up on the art (or here) for the queue. // Swipe up on the art (or here) for the queue. Docked, the queue is already beside it.
Button { show(.Queue) } label: { if !docked { Button { show(.Queue) } label: {
VStack(spacing: 0) { VStack(spacing: 0) {
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium)) Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) } if panel != .Queue && !tight { Text("Playing Next").font(.caption2.weight(.medium)) }
} }
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle()) .foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: tight ? 24 : 30).contentShape(Rectangle())
} }
.buttonStyle(PressFade()) .buttonStyle(PressFade())
.gesture(swipe(up: panel != .Queue)) .gesture(swipe(up: panel != .Queue)) }
} }
.padding(.horizontal, 28) .padding(.horizontal, tight ? 20 : 28)
} }
/// Opens a panel, or goes back to the art if it's already open. /// Opens a panel, or goes back to the art if it's already open.
@@ -243,6 +261,8 @@ struct NowPlayingScreen: View {
} }
private func show(_ to: NpPanel) { private func show(_ to: NpPanel) {
// Docked, the stems and the queue have the other side of the hinge to themselves.
if docked && (to == .Mixer || to == .Queue) { withAnimation(.snappy) { ui.showDeck() }; return }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to } withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
} }
@@ -251,6 +271,7 @@ struct NowPlayingScreen: View {
DragGesture(minimumDistance: 20).onEnded { v in DragGesture(minimumDistance: 20).onEnded { v in
let dy = v.translation.height let dy = v.translation.height
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return } guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
if up && !g.settings.swipeUpQueue { return }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art } withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
} }
} }
@@ -259,14 +280,17 @@ struct NowPlayingScreen: View {
DragGesture(minimumDistance: 20) DragGesture(minimumDistance: 20)
.onChanged { v in .onChanged { v in
let dy = v.translation.height let dy = v.translation.height
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0 drop = !docked && dy > 0 && dy > abs(v.translation.width) ? dy : 0
} }
.onEnded { v in .onEnded { v in
let dy = v.translation.height let dy = v.translation.height
let vertical = abs(dy) > abs(v.translation.width) let vertical = abs(dy) > abs(v.translation.width)
if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return } if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 } withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } } // Never from the stems: a slightly upward fader drag or scroll there kept opening the queue.
if vertical && dy < -50 && panel != .Mixer && g.settings.swipeUpQueue {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue }
}
} }
} }
@@ -418,6 +442,7 @@ private struct EllipsisButton: View {
private struct ArtPanel: View { private struct ArtPanel: View {
let thumb: String? let thumb: String?
let playing: Bool let playing: Bool
var inset: CGFloat = 28
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let side = max(min(geo.size.width, geo.size.height), 1) let side = max(min(geo.size.width, geo.size.height), 1)
@@ -427,7 +452,7 @@ private struct ArtPanel: View {
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing) .animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
} }
.padding(.horizontal, 28).padding(.vertical, 8) .padding(.horizontal, inset).padding(.vertical, 8)
} }
} }
@@ -643,6 +668,9 @@ private final class Meters {
} }
private struct MixerPanel: View { private struct MixerPanel: View {
/// The iPhone Duo deck: each stem's name sits over its fader so the fader gets the row's full
/// width, with a bigger thumb for fine moves.
var roomy = false
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@Environment(\.palette) private var p @Environment(\.palette) private var p
@State private var meters = Meters() @State private var meters = Meters()
@@ -653,8 +681,10 @@ private struct MixerPanel: View {
let names = pm.stemNames let names = pm.stemNames
let stems = pm.source == .Stems let stems = pm.source == .Stems
GeometryReader { geo in GeometryReader { geo in
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom. // Spread spare height across the stem rows instead of leaving it all at the bottom.
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1))) let count = CGFloat(max(names.count, 1))
let rowH = roomy ? min(80, max(Self.roomyRow, (geo.size.height - Self.roomyChrome) / count))
: min(62, max(46, (geo.size.height - 64) / count))
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if pm.source == .Original { if pm.source == .Original {
@@ -718,15 +748,11 @@ 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, height: rowH) StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH, roomy: roomy)
} }
} }
} }
p.separator.frame(height: 0.5).padding(.vertical, 6) Spacer(minLength: roomy ? 8 : 16)
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
} trailing: { EmptyView() }
Spacer(minLength: 16)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
@@ -737,6 +763,14 @@ private struct MixerPanel: View {
} }
} }
extension MixerPanel {
static let roomyRow: CGFloat = 66
/// Preset chips plus the bottom gap.
static let roomyChrome: CGFloat = 56
/// Height that shows the presets and every stem without scrolling, roomy rows.
static func roomyHeight(_ stems: Int) -> CGFloat { roomyChrome + CGFloat(stems) * roomyRow }
}
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint. /// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
private struct PresetChip: View { private struct PresetChip: View {
let text: String let text: String
@@ -765,31 +799,22 @@ private struct StemFader: View {
let level: Float let level: Float
let enabled: Bool let enabled: Bool
var height: CGFloat = 46 var height: CGFloat = 46
var roomy = false
@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))
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) { FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height, stacked: roomy) {
VStack(spacing: 0) { // Live level, -48..0 dBFS, drawn as a light band inside the fader's fill.
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))) let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
ZStack(alignment: .leading) { FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled, thumb: roomy ? 26 : 24, meter: enabled && !setting.muted ? f : nil) {
Capsule().fill(p.fill) pm.setStemLevel(name, $0)
Capsule().fill(color).frame(width: geo.size.width * f)
}
}
.frame(height: 3)
.padding(.horizontal, 12)
} }
} trailing: { } trailing: {
if enabled { if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) } ToggleBox(text: "M", on: setting.muted, onColor: p.red, big: roomy) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) } ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00), big: roomy) { pm.toggleSolo(name) }
} }
} }
} }
@@ -803,10 +828,32 @@ struct FaderRow<Fader: View, Trailing: View>: View {
let percent: Int let percent: Int
let dim: Bool let dim: Bool
var height: CGFloat = 46 var height: CGFloat = 46
/// Name and level on a line of their own over the fader, so the fader runs the full width.
var stacked = false
@ViewBuilder let fader: Fader @ViewBuilder let fader: Fader
@ViewBuilder let trailing: Trailing @ViewBuilder let trailing: Trailing
@Environment(\.palette) private var p @Environment(\.palette) private var p
var body: some View { var body: some View {
if stacked {
// Bottom-aligned with a gap under, so each name sits clear of the meter above it.
VStack(alignment: .leading, spacing: 2) {
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)
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
Spacer(minLength: 0)
}
.frame(height: 18)
HStack(spacing: 8) { fader; trailing }
}
.padding(.bottom, 4)
.frame(height: height, alignment: .bottom)
} else {
row
}
}
private var row: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
HStack(spacing: 6) { HStack(spacing: 6) {
if let dot { Circle().fill(dot).frame(width: 8, height: 8) } if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
@@ -827,12 +874,13 @@ struct ToggleBox: View {
let text: String let text: String
let on: Bool let on: Bool
let onColor: Color let onColor: Color
var big = false
let action: () -> Void let action: () -> Void
@Environment(\.palette) private var p @Environment(\.palette) private var p
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2) Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
.frame(width: 30, height: 26) .frame(width: big ? 34 : 30, height: big ? 28 : 26)
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) .background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -847,23 +895,32 @@ struct FaderSlider: View {
var detent: Float? var detent: Float?
var enabled = true var enabled = true
var thumb: CGFloat = 24 var thumb: CGFloat = 24
/// 0...1 live level, shown as a lighter band over the filled part of the track.
var meter: CGFloat?
let onChange: (Float) -> Void let onChange: (Float) -> Void
@Environment(\.palette) private var p @Environment(\.palette) private var p
@State private var start: Float? @State private var start: Float?
@State private var caught = false @State private var caught = false
init(value: Float, fill: Color, detent: Float? = nil, enabled: Bool = true, thumb: CGFloat = 24, onChange: @escaping (Float) -> Void) { init(value: Float, fill: Color, detent: Float? = nil, enabled: Bool = true, thumb: CGFloat = 24, meter: CGFloat? = nil, onChange: @escaping (Float) -> Void) {
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.meter = meter; self.onChange = onChange
} }
private var bar: CGFloat { meter == nil ? (thumb >= 26 ? 5 : 4) : 6 }
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let track = max(geo.size.width - thumb, 1) let track = max(geo.size.width - thumb, 1)
let x = CGFloat(min(max(value, 0), 1)) * track let x = CGFloat(min(max(value, 0), 1)) * track
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2) Capsule().fill(p.fillStrong).frame(height: bar).padding(.horizontal, thumb / 2)
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01) Capsule().fill(fill).frame(width: x + thumb / 2, height: bar).padding(.leading, thumb / 2 - 0.01)
if let meter, meter > 0.01 {
Capsule().fill(.white.opacity(0.5)).frame(width: meter * (x + thumb / 2), height: bar)
.padding(.leading, thumb / 2 - 0.01)
.animation(.linear(duration: 1 / 30), value: meter)
}
if let detent { if let detent {
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1) p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
} }
@@ -885,12 +942,52 @@ struct FaderSlider: View {
onChange(v) onChange(v)
}.onEnded { _ in start = nil }) }.onEnded { _ in start = nil })
} }
.frame(height: 36) .frame(height: max(36, thumb + 8))
.opacity(enabled ? 1 : 0.4) .opacity(enabled ? 1 : 0.4)
.sensoryFeedback(.selection, trigger: caught) { _, now in now } .sensoryFeedback(.selection, trigger: caught) { _, now in now }
} }
} }
// ---- deck -----------------------------------------------------------------------------------
/// Unfolded iPhone Duo: the stems over the queue, on the other side of the hinge from Now Playing.
struct NowPlayingDeck: View {
@Environment(\.palette) private var base
@State private var tint: Color?
private var g: AppGraph { .shared }
var body: some View {
let c = tint.map { base.tinted($0) } ?? base
let track = g.playback.current?.track
GeometryReader { geo in
if track != nil {
VStack(spacing: 0) {
Text("Stems").font(.headline).foregroundStyle(c.label)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 24)
// As much as the stems need to show without scrolling, leaving the queue a few rows.
MixerPanel(roomy: true).frame(height: min(MixerPanel.roomyHeight(max(g.playback.stemNames.count, 4)), geo.size.height * 0.72))
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
QueuePanel()
}
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(.top, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.environment(\.palette, c)
.modifier(NpScheme(dark: c.dark, docked: true))
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
tint = await albumColor(thumb, dark: base.dark)
}
}
}
// ---- queue ---------------------------------------------------------------------------------- // ---- queue ----------------------------------------------------------------------------------
private struct QueuePanel: View { private struct QueuePanel: View {
+3 -4
View File
@@ -397,7 +397,7 @@ struct BackupCells: View {
var body: some View { var body: some View {
Button { Button {
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true } do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.playback, g.settings).encoded()); exporting = true }
catch { message = error.localizedDescription } catch { message = error.localizedDescription }
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) } } label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
.themedRow() .themedRow()
@@ -412,9 +412,8 @@ struct BackupCells: View {
let scoped = url.startAccessingSecurityScopedResource() let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } } defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let x = try StemifyExport.decode(Data(contentsOf: url)) let x = try StemifyExport.decode(Data(contentsOf: url))
let n = g.stemPlaylists.merge(x.playlists) let r = x.restore(g.stemPlaylists, g.playback, g.settings)
x.apply(to: g.settings) message = "Restored \(r.playlists) playlist\(r.playlists == 1 ? "" : "s"), \(r.mixes) song mix\(r.mixes == 1 ? "" : "es"), your presets and sound settings."
message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings."
} catch { message = error.localizedDescription } } catch { message = error.localizedDescription }
} }
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { .alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
+15 -9
View File
@@ -8,8 +8,12 @@ settings:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
# iOS needs a plain number for the bundle version; the label is what the app and releases show. # iOS needs a plain number for the bundle version; the label is what the app and releases show.
MARKETING_VERSION: "1.0" MARKETING_VERSION: "1.0"
VERSION_LABEL: "1.0 RC 2" VERSION_LABEL: "1.0 RC 4"
CURRENT_PROJECT_VERSION: "22" CURRENT_PROJECT_VERSION: "24"
# Dev builds override these (build-ipa.sh dev) so they install next to the release app.
APP_ID: org.cdnutter.stemify
APP_NAME: Stemify
APP_ICON: AppIcon
targets: targets:
Stemify: Stemify:
type: application type: application
@@ -19,20 +23,21 @@ targets:
- target: StemifyWidgets - target: StemifyWidgets
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify PRODUCT_BUNDLE_IDENTIFIER: $(APP_ID)
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: $(APP_ICON)
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
entitlements: entitlements:
path: Stemify/Stemify.entitlements path: Stemify/Stemify.entitlements
properties: properties:
com.apple.developer.carplay-audio: true com.apple.developer.carplay-audio: true
com.apple.developer.siri: true com.apple.developer.siri: true
com.apple.security.application-groups: [group.org.cdnutter.stemify] com.apple.security.application-groups: [group.$(APP_ID)]
info: info:
path: Stemify/Info.plist path: Stemify/Info.plist
properties: properties:
CFBundleDisplayName: Stemify CFBundleDisplayName: $(APP_NAME)
StemifyAppGroup: group.$(APP_ID)
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
StemifyVersionLabel: $(VERSION_LABEL) StemifyVersionLabel: $(VERSION_LABEL)
@@ -63,17 +68,18 @@ targets:
sources: [Widgets, Shared] sources: [Widgets, Shared]
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify.widgets PRODUCT_BUNDLE_IDENTIFIER: $(APP_ID).widgets
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
CODE_SIGN_ENTITLEMENTS: Widgets/StemifyWidgets.entitlements CODE_SIGN_ENTITLEMENTS: Widgets/StemifyWidgets.entitlements
entitlements: entitlements:
path: Widgets/StemifyWidgets.entitlements path: Widgets/StemifyWidgets.entitlements
properties: properties:
com.apple.security.application-groups: [group.org.cdnutter.stemify] com.apple.security.application-groups: [group.$(APP_ID)]
info: info:
path: Widgets/Info.plist path: Widgets/Info.plist
properties: properties:
CFBundleDisplayName: Stemify CFBundleDisplayName: $(APP_NAME)
StemifyAppGroup: group.$(APP_ID)
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension: NSExtension:
+35 -22
View File
@@ -1,39 +1,54 @@
#!/bin/bash #!/bin/bash
# Builds build/public/stemify-<version>.ipa on a Mac: ad-hoc signed, for releases and re-signing with Sideloadly/AltStore. # Builds one Stemify IPA on a Mac. ios/scripts/build-remote.sh runs this on WHITE-M1 from DevOps.
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed # build-ipa.sh release build/release/stemify-<v>.ipa org.cdnutter.stemify-pub, ad-hoc signed. Gitea releases (main) + Sideload.
# build/prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device. # build-ipa.sh dev build/dev/stemify-<v>-dev.ipa org.cdnutter.stemify-dev, ribbon icon.
# Finished IPAs are kept per version; build/work is scratch and is wiped each run. # Gitea pre-releases (dev branch) + Sideload Developer.
# ios/scripts/build-remote.sh runs this on White-M1 and copies build/public and build/prov back to DevOps. # build-ipa.sh prov build/prov/stemify-<v>-prov.ipa org.cdnutter.stemify, signed with the
# personal profile (CarPlay, Siri). Never published anywhere.
# prov needs prov/Stemify.mobileprovision (App ID org.cdnutter.stemify), prov/key.p12 and P12_PASS.
# prov/StemifyWidgets.mobileprovision (org.cdnutter.stemify.widgets) is optional, without it the widgets are left out.
# build/work is scratch and is wiped each run.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
export PATH=/opt/homebrew/bin:$PATH export PATH=/opt/homebrew/bin:$PATH
B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex
rm -rf $OUT && mkdir -p $OUT/Payload $B/public $B/prov MODE=${1:-dev}
case $MODE in
release) APP_ID=org.cdnutter.stemify-pub; APP_NAME=Stemify; APP_ICON=AppIcon; SUFFIX= ;;
dev) APP_ID=org.cdnutter.stemify-dev; APP_NAME=Stemify; APP_ICON=AppIconDev; SUFFIX=-dev ;;
prov) APP_ID=org.cdnutter.stemify; APP_NAME=Stemify; APP_ICON=AppIcon; SUFFIX=-prov ;;
*) echo "usage: $0 release|dev|prov" >&2; exit 1 ;;
esac
DD=$B/dd-$MODE
# "1.0 RC 1" -> 1.0-rc1, used in file names and release tags. # "1.0 RC 1" -> 1.0-rc1, used in file names and release tags.
VERSION=$(sed -nE 's/^ *VERSION_LABEL: "(.*)"/\1/p' project.yml | tr 'A-Z' 'a-z' | sed -E 's/ rc /-rc/; s/ /-/g') VERSION=$(sed -nE 's/^ *VERSION_LABEL: "(.*)"/\1/p' project.yml | tr 'A-Z' 'a-z' | sed -E 's/ (rc|beta) /-\1/; s/ /-/g')
IPA=$B/$MODE/stemify-$VERSION$SUFFIX.ipa
if [ "$MODE" = prov ]; then
for f in prov/Stemify.mobileprovision prov/key.p12; do [ -f $f ] || { echo "missing $f" >&2; exit 1; }; done
[ -n "${P12_PASS:-}" ] || { echo "P12_PASS not set" >&2; exit 1; }
PROF_ID=$(security cms -D -i prov/Stemify.mobileprovision | plutil -extract Entitlements.application-identifier raw -)
[ "${PROF_ID#*.}" = "$APP_ID" ] || { echo "prov/Stemify.mobileprovision is for ${PROF_ID#*.}, need a profile for $APP_ID" >&2; exit 1; }
fi
rm -rf $OUT && mkdir -p $OUT/Payload $B/$MODE
# xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata. # xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata.
xcodegen generate -q xcodegen generate -q
xcodebuild -project Stemify.xcodeproj -scheme Stemify -configuration Release -sdk iphoneos \ xcodebuild -project Stemify.xcodeproj -scheme Stemify -configuration Release -sdk iphoneos \
-destination generic/platform=iOS -derivedDataPath $B/dd CODE_SIGNING_ALLOWED=NO -quiet build -destination generic/platform=iOS -derivedDataPath $DD APP_ID=$APP_ID APP_NAME="$APP_NAME" APP_ICON=$APP_ICON CODE_SIGNING_ALLOWED=NO -quiet build
cp -R $B/dd/Build/Products/Release-iphoneos/Stemify.app $APP cp -R $DD/Build/Products/Release-iphoneos/Stemify.app $APP
[ -d $EXT ] || { echo "widget extension missing" >&2; exit 1; } [ -d $EXT ] || { echo "widget extension missing" >&2; exit 1; }
if [ "$MODE" != prov ]; then
# Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing. # Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing.
cat > $OUT/group.plist <<PL cat > $OUT/group.plist <<PL
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>com.apple.security.application-groups</key><array><string>group.org.cdnutter.stemify</string></array></dict></plist> <plist version="1.0"><dict><key>com.apple.security.application-groups</key><array><string>group.$APP_ID</string></array></dict></plist>
PL PL
codesign -f -s - --entitlements $OUT/group.plist $EXT codesign -f -s - --entitlements $OUT/group.plist $EXT
codesign -f -s - --entitlements $OUT/group.plist $APP codesign -f -s - --entitlements $OUT/group.plist $APP
else
# Public build, for the releases and AltStore/Sideloadly.
rm -f $B/public/stemify-$VERSION.ipa
(cd $OUT && zip -qry ../public/stemify-$VERSION.ipa Payload)
echo "$B/public/stemify-$VERSION.ipa"
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen) KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
security create-keychain -p "$KP" $KC security create-keychain -p "$KP" $KC
security set-keychain-settings -lut 3600 $KC security set-keychain-settings -lut 3600 $KC
@@ -47,7 +62,6 @@ if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PAS
ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1) 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 security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist
# The widgets need their own profile (org.cdnutter.stemify.widgets with the App Group). Without one they're left out.
if [ -f prov/StemifyWidgets.mobileprovision ]; then if [ -f prov/StemifyWidgets.mobileprovision ]; then
security cms -D -i prov/StemifyWidgets.mobileprovision > $OUT/wprov.plist security cms -D -i prov/StemifyWidgets.mobileprovision > $OUT/wprov.plist
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/wprov.plist > $OUT/went.plist /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/wprov.plist > $OUT/went.plist
@@ -60,9 +74,8 @@ if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PAS
codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP 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 list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain)
security delete-keychain $KC security delete-keychain $KC
# Personal build for this device only: stays in build/prov (gitignored), never released.
rm -f $B/prov/stemify-$VERSION-signed.ipa
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
echo "$B/prov/stemify-$VERSION-signed.ipa (signed as $ID)"
fi fi
rm -f $IPA
(cd $OUT && zip -qry ../$MODE/$(basename $IPA) Payload)
echo "$IPA"
+13 -7
View File
@@ -1,11 +1,17 @@
#!/bin/bash #!/bin/bash
# Run from DevOps: syncs ios/ to White-M1, builds there, and pulls the IPAs back into ios/build/{public,prov}. # Run from DevOps: syncs ios/ to WHITE-M1, builds there, and pulls the IPAs back into ios/build/<mode>.
# build-remote.sh dev build (the usual one): build/dev
# build-remote.sh release release build, only when a release is asked for: build/release
# build-remote.sh prov personal signed build: build/prov
# build-remote.sh dev prov ... several at once
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
H=${BUILD_HOST:-white}; R=stemify-build/ios H=${BUILD_HOST:-white-m1}; R=stemify-build/ios
MODES=${*:-dev}
for m in $MODES; do case $m in release|dev|prov) ;; *) echo "usage: $0 [release|dev|prov]..." >&2; exit 1 ;; esac; done
rsync -a --exclude build --exclude prov --exclude Stemify.xcodeproj ./ $H:$R/ rsync -a --exclude build --exclude prov --exclude Stemify.xcodeproj ./ $H:$R/
ssh $H "cd $R && P12_PASS=\$(cat prov/cert_pass.txt 2>/dev/null) bash scripts/build-ipa.sh" for m in $MODES; do
mkdir -p build/public build/prov ssh $H "cd $R && P12_PASS=\$(cat prov/cert_pass.txt 2>/dev/null) bash scripts/build-ipa.sh $m"
rsync -a $H:$R/build/public/ build/public/ mkdir -p build/$m && rsync -a $H:$R/build/$m/ build/$m/
rsync -a $H:$R/build/prov/ build/prov/ done
ls build/public build/prov ls build/{release,dev,prov}/stemify-$(sed -nE 's/^ *VERSION_LABEL: "(.*)"/\1/p' project.yml | tr 'A-Z' 'a-z' | sed -E 's/ (rc|beta) /-\1/; s/ /-/g')*.ipa 2>/dev/null
+3 -2
View File
@@ -36,7 +36,8 @@ versions = []
for rel in get(API + "/releases?limit=50"): for rel in get(API + "/releases?limit=50"):
v = rel["tag_name"].removeprefix("ios-v") v = rel["tag_name"].removeprefix("ios-v")
ipa = next((a for a in rel["assets"] if a["name"] == f"stemify-{v}.ipa"), None) ipa = next((a for a in rel["assets"] if a["name"] == f"stemify-{v}.ipa"), None)
if not rel["tag_name"].startswith("ios-v") or not ipa: # Pre-releases are dev builds (org.cdnutter.stemify-dev), they go to Sideload Developer instead.
if not rel["tag_name"].startswith("ios-v") or not ipa or rel["prerelease"]:
continue continue
versions.append({ versions.append({
"version": v.split("-")[0], "version": v.split("-")[0],
@@ -57,7 +58,7 @@ source = {
"website": REPO, "website": REPO,
"apps": [{ "apps": [{
"name": "Stemify", "name": "Stemify",
"bundleIdentifier": "org.cdnutter.stemify", "bundleIdentifier": "org.cdnutter.stemify-pub",
"developerName": "cdnutter", "developerName": "cdnutter",
"subtitle": "Plex music player with live stem mixing.", "subtitle": "Plex music player with live stem mixing.",
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. " "localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. "
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Publishes an IPA built by build-remote.sh. Run from DevOps with everything committed.
# publish.sh dev on the dev branch: Gitea pre-release ios-v<v>-dev.<build> + Sideload Developer (WHITE-M1 :8093, ?source=developer)
# publish.sh release on main, only when the owner says so: Gitea release ios-v<v> + Sideload (:8093), then source.json
# Release notes come from $NOTES, else the last commit subject. prov builds are never published.
set -euo pipefail
cd "$(dirname "$0")/.."
MODE=${1:-}; NAME=stemify; TAGP=ios-v
API=https://git.iamchrisama.com/api/v1/repos/cdnutter/stemify
T=$(sed -n 's#.*://cdnutter:\([^@]*\)@.*#\1#p' ~/.config/gitea/cdnutter.credentials | head -1)
V=$(sed -nE 's/^ *VERSION_LABEL: "(.*)"/\1/p' project.yml | tr 'A-Z' 'a-z' | sed -E 's/ (rc|beta) /-\1/; s/ /-/g')
BUILD=$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: "(.*)"/\1/p' project.yml)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
die() { echo "$*" >&2; exit 1; }
case $MODE in
dev) WANT=dev; IPA=build/dev/$NAME-$V-dev.ipa; TAG=$TAGP$V-dev.$BUILD; PRE=true; UP=http://10.0.0.11:8093; Q='?source=developer' ;;
release) WANT=main; IPA=build/release/$NAME-$V.ipa; TAG=$TAGP$V; PRE=false; UP=http://10.0.0.11:8093; Q= ;;
*) die "usage: $0 dev|release" ;;
esac
[ "$BRANCH" = "$WANT" ] || die "$MODE builds publish from $WANT (on $BRANCH)"
[ -f "$IPA" ] || die "missing $IPA, run scripts/build-remote.sh $MODE"
git diff --quiet HEAD || die "uncommitted changes"
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $T" $API/releases/tags/$TAG)" = 404 ] \
|| die "$TAG already exists, bump CURRENT_PROJECT_VERSION (dev) or VERSION_LABEL (release)"
git push -q -u origin $BRANCH
BODY=${NOTES:-$(git log -1 --format=%s)}
ID=$(jq -n --arg t "$TAG" --arg c "$(git rev-parse HEAD)" --arg b "$BODY" --argjson p $PRE \
'{tag_name:$t, target_commitish:$c, name:$t, body:$b, prerelease:$p}' |
curl -fsS -H "Authorization: token $T" -H 'Content-Type: application/json' -d @- $API/releases | jq -r .id)
curl -fsS -o /dev/null -H "Authorization: token $T" -F "attachment=@$IPA" "$API/releases/$ID/assets?name=$(basename $IPA)"
curl -fsS -o /dev/null -X POST --data-binary @$IPA "$UP/api/upload/ipa$Q"
echo "published $TAG ($(basename $IPA)) to Gitea and $UP"
if [ $MODE = release ]; then
GITEA_TOKEN=$T python3 scripts/make-source.py
git add source.json && git commit -qm "iOS source.json for $TAG" && git push -q origin main
fi
+43 -3
View File
@@ -15,14 +15,24 @@
"tintColor": "#FA2D48", "tintColor": "#FA2D48",
"category": "entertainment", "category": "entertainment",
"versions": [ "versions": [
{
"version": "1.0",
"marketingVersion": "1.0 RC 3",
"buildVersion": "23",
"date": "2026-11-09T08:01:30-08:00",
"localizedDescription": "iPhone Duo: unfolded, the library sits on one side of the hinge and Now Playing stays docked on the other (no mini player while split). Folded and other iPhones are unchanged. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0-rc3/stemify-1.0-rc3.ipa",
"size": 2596161,
"minOSVersion": "17.0"
},
{ {
"version": "1.0", "version": "1.0",
"marketingVersion": "1.0 RC 2", "marketingVersion": "1.0 RC 2",
"buildVersion": "22", "buildVersion": "22",
"date": "2026-11-06T08:41:04-08:00", "date": "2026-11-06T08:55:28-08:00",
"localizedDescription": "Plain Plex songs play when Remote Access has an upload speed limit (no more 500s). Shuffle buttons work and top up as you go. Random Album Radio works like Plexamp: a shuffled album list played album by album, in track order. Recently Added has See All with the full list. Stem playlist covers can come from Photos. Crossfade (Off or 1-12 s, Settings > Listening) with Sweet Fades for same-album songs. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.", "localizedDescription": "Plain Plex songs play when Remote Access has an upload speed limit (no more 500s). Shuffle buttons work and top up as you go. Random Album Radio works like Plexamp: a shuffled album list played album by album, in track order. Recently Added has See All with the full list. Stem playlist covers can come from Photos. Crossfade (Off or 1-12 s, Settings > Listening) with Sweet Fades for same-album songs. Update Download on downloaded playlists, albums and artists, plus Update All Downloads. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0-rc2/stemify-1.0-rc2.ipa", "downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0-rc2/stemify-1.0-rc2.ipa",
"size": 2576564, "size": 2579022,
"minOSVersion": "17.0" "minOSVersion": "17.0"
}, },
{ {
@@ -164,6 +174,36 @@
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa", "downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa",
"size": 1319672, "size": 1319672,
"minOSVersion": "17.0" "minOSVersion": "17.0"
},
{
"version": "1.0.2",
"marketingVersion": "1.0.2",
"buildVersion": "1.0.2",
"date": "2026-10-20T08:53:24-07:00",
"localizedDescription": "Unsigned, iOS 17 or newer. Sideload `Stemify.ipa` with Sideloadly or AltStore.\n\n- Shuffle on the Songs screen now picks from the whole library instead of only the songs loaded so far (the A's and B's)\n\nIncludes everything in 1.0.1. CarPlay and Siri still need entitlements a free Apple ID can't sign.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.2/stemify-1.0.2.ipa",
"size": 1319672,
"minOSVersion": "17.0"
},
{
"version": "1.0.1",
"marketingVersion": "1.0.1",
"buildVersion": "1.0.1",
"date": "2026-10-20T08:50:56-07:00",
"localizedDescription": "Unsigned, iOS 17 or newer. Sideload `Stemify.ipa` with Sideloadly or AltStore.\n\n- The mini player no longer covers the bottom of lists, and it is now a solid card that matches the app instead of system glass\n- More room between the title and the scrubber on Now Playing\n- The play button no longer spins forever after you change tracks\n- The stems and queue buttons now animate their panels in and out\n- Springier button presses and play/pause transitions\n\nCarPlay and Siri still need entitlements a free Apple ID can't sign.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.1/stemify-1.0.1.ipa",
"size": 1317680,
"minOSVersion": "17.0"
},
{
"version": "1.0",
"marketingVersion": "1.0",
"buildVersion": "1.0",
"date": "2026-10-20T07:16:41-07:00",
"localizedDescription": "First iOS build. Unsigned, iOS 17 or newer.\n\nSideload `Stemify.ipa` with Sideloadly or AltStore, which sign it with your Apple ID.\n\nCarPlay and Siri need entitlements a free Apple ID can't sign, so on a free sideload they won't show up. Everything on the phone works. Not yet tested on a device.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0/stemify-1.0.ipa",
"size": 1305649,
"minOSVersion": "17.0"
} }
], ],
"appPermissions": { "appPermissions": {
+11 -4
View File
@@ -93,17 +93,23 @@ class Index:
self._by_key: dict[str, dict] = {} self._by_key: dict[str, dict] = {}
def get(self): def get(self):
if time.monotonic() - self._at > INDEX_TTL:
self.refresh()
with self._lock: with self._lock:
return self._body, self._etag return self._body, self._etag
def entry(self, key): def entry(self, key):
if time.monotonic() - self._at > INDEX_TTL:
self.refresh()
with self._lock: with self._lock:
return self._by_key.get(key) return self._by_key.get(key)
def start_background(self):
def loop():
while True:
time.sleep(INDEX_TTL)
try:
self.refresh()
except Exception as e:
log(f"index refresh failed: {e}")
threading.Thread(target=loop, daemon=True).start()
@property @property
def count(self): def count(self):
return self._count return self._count
@@ -537,6 +543,7 @@ def main():
sys.exit(f"STEMS_DIR not found: {STEMS_DIR}") sys.exit(f"STEMS_DIR not found: {STEMS_DIR}")
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
INDEX.refresh() INDEX.refresh()
INDEX.start_background()
log(f"stemserve on :{PORT}, {INDEX.count} tracks in {STEMS_DIR}, ffmpeg={'yes' if FFMPEG else 'no'}, " log(f"stemserve on :{PORT}, {INDEX.count} tracks in {STEMS_DIR}, ffmpeg={'yes' if FFMPEG else 'no'}, "
f"token={'on' if TOKEN else 'off'}") f"token={'on' if TOKEN else 'off'}")
server = QuietServer(("0.0.0.0", PORT), Handler) server = QuietServer(("0.0.0.0", PORT), Handler)