Compare commits
13
Commits
ios-v1.0-rc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f8452be2 | ||
|
|
567e557968 | ||
|
|
ae5eead73b | ||
|
|
5a7a36b898 | ||
|
|
f9933a4883 | ||
|
|
ec1cbdfe75 | ||
|
|
fabca5d1aa | ||
|
|
e8bc920454 | ||
|
|
08f7af9421 | ||
|
|
f4af7b6cf8 | ||
|
|
d996d3e236 | ||
|
|
4f2dcb94f5 | ||
|
|
dfdd9a8ae5 |
No files matched your search
@@ -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`.
|
||||||
@@ -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):
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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 |
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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. "
|
||||||
|
|||||||
Executable
+36
@@ -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
|
||||||
@@ -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": {
|
||||||
|
|||||||
Reference in new issue
Block a user