13 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
22 changed files with 505 additions and 138 deletions

No files matched your search

+4
View File
@@ -29,3 +29,7 @@
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: 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`.
+12 -4
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
xcodegen generate # or open Stemify.xcodeproj in Xcode 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
``` ```
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 n > 0 { save() } if !incoming.isEmpty { save() }
return n return incoming.count
} }
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

+13 -1
View File
@@ -53,6 +53,8 @@ final class Ui {
} }
/// True while an unfolded iPhone Duo shows Now Playing docked beside the library. /// True while an unfolded iPhone Duo shows Now Playing docked beside the library.
private(set) var split = false 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 }
@@ -67,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 }
} }
@@ -89,15 +92,23 @@ final class Ui {
/// Docked Now Playing is already on screen, so there's nothing to open while split. /// Docked Now Playing is already on screen, so there's nothing to open while split.
func openNowPlaying() { if !split { nowPlayingOpen = true } } 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) { func setSplit(_ on: Bool) {
guard on != split else { return } guard on != split else { return }
split = on split = on
if on { nowPlayingOpen = false } 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)
@@ -106,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) }
} }
+103 -16
View File
@@ -1,15 +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 the library sits on one side of the hinge and Now Playing on the other. /// 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(onSplit: { ui.setSplit($0) }) { 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)) {
@@ -22,8 +26,12 @@ struct RootView: View {
} }
} }
.modifier(GlassMiniPlayerHost()) .modifier(GlassMiniPlayerHost())
} deck: {
NowPlayingDeck()
} docked: { } docked: {
NowPlayingScreen(docked: true) NowPlayingScreen(docked: true)
} rail: {
DuoRail(swap: $duoSwap)
} }
.background(p.bg.ignoresSafeArea()) .background(p.bg.ignoresSafeArea())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() } .onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
@@ -134,6 +142,8 @@ private struct MiniPlayerRoom: ViewModifier {
content.safeAreaInset(edge: .bottom, spacing: 0) { content.safeAreaInset(edge: .bottom, spacing: 0) {
if !glassAccessory && !ui.split && 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)
} }
} }
@@ -141,28 +151,55 @@ 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] on the /// Splits the screen at the iPhone Duo's hinge (the system's division region). [library] (with
/// leading or top side, [docked] on the other. Anywhere without an active hinge it's just [library]. /// [deck] over it while [showDeck]) goes on one side and [docked] on the other, left and right
/// One layout for both states keeps the library's tab and scroll state across fold and unfold. /// trading places with [swap]; [rail] runs down the right edge in the status bar's strip.
private struct DuoSplit<Library: View, Docked: View>: View { /// 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 var onSplit: (Bool) -> Void
@ViewBuilder var library: Library @ViewBuilder var library: Library
@ViewBuilder var deck: Deck
@ViewBuilder var docked: Docked @ViewBuilder var docked: Docked
@ViewBuilder var rail: Rail
private static var railWidth: CGFloat { 64 }
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let cut = Self.cut(geo) 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)) let layout = cut?.vertical == false ? AnyLayout(VStackLayout(spacing: cut?.gap ?? 0)) : AnyLayout(HStackLayout(spacing: cut?.gap ?? 0))
layout { layout {
library ZStack {
.frame(width: cut.map { $0.vertical ? $0.first : geo.size.width } ?? geo.size.width, library
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height) 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 { if let cut {
docked docked
.frame(width: cut.vertical ? cut.second : geo.size.width, .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) 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) } .onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
} }
} }
@@ -171,14 +208,64 @@ private struct DuoSplit<Library: View, Docked: View>: View {
private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat } private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat }
private static func cut(_ geo: GeometryProxy) -> Cut? { private static func cut(_ geo: GeometryProxy) -> Cut? {
guard #available(iOS 27.1, *),
let r = geo.reservedRegions(kind: .division).first(where: \.isActive)?.frame else { return nil }
let size = geo.size let size = geo.size
let vertical = r.height >= r.width var c: Cut?
let c = vertical // The hinge reports inactive while the phone lies flat, so ask for inactive regions too.
? Cut(vertical: true, first: r.minX, gap: r.width, second: size.width - r.maxX) if #available(iOS 27.1, *),
: Cut(vertical: false, first: r.minY, gap: r.height, second: size.height - r.maxY) 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. // 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 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) {
+131 -48
View File
@@ -108,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 {
@@ -133,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)
} }
@@ -142,8 +146,8 @@ 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) {
@@ -172,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) }
@@ -195,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()
@@ -220,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.
@@ -257,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 }
} }
@@ -265,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 }
} }
} }
@@ -280,7 +287,10 @@ struct NowPlayingScreen: View {
let vertical = abs(dy) > abs(v.translation.width) let vertical = abs(dy) > abs(v.translation.width)
if !docked && 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 }
}
} }
} }
@@ -432,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)
@@ -441,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)
} }
} }
@@ -657,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()
@@ -667,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 {
@@ -732,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)
} }
@@ -751,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
@@ -779,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) { let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
pm.setStemLevel(name, $0) 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) {
} 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)
}
}
.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) }
} }
} }
} }
@@ -817,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) }
@@ -841,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)
@@ -861,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)
} }
@@ -899,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 3" VERSION_LABEL: "1.0 RC 4"
CURRENT_PROJECT_VERSION: "23" 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:
+39 -26
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; }
# Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing. if [ "$MODE" != prov ]; then
cat > $OUT/group.plist <<PL # Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing.
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"
+12 -6
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-m1}; 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
+40
View File
@@ -15,6 +15,16 @@
"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",
@@ -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": {