Compare commits
11
Commits
ios-v1.0.1
...
ios-v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52a3e257f | ||
|
|
e98d595365 | ||
|
|
41b8171df2 | ||
|
|
8e5ebb70b1 | ||
|
|
65bc69a7fe | ||
|
|
d149af336a | ||
|
|
3f7fd76762 | ||
|
|
bea3f8f5a2 | ||
|
|
e0184f540b | ||
|
|
1123f460b0 | ||
|
|
a0726b07a1 |
No files matched your search
@@ -363,14 +363,24 @@ adb forward tcp:5277 tcp:5277
|
|||||||
|
|
||||||
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
|
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
|
||||||
|
|
||||||
|
On top of the Android features: the Save chip in the mixer remembers a mix for that song and puts it back every time it plays, and the mic button turns on karaoke mode (vocals muted on every song, synced lyrics from [LRCLIB](https://lrclib.net) on Now Playing; tap a line to jump to it).
|
||||||
|
|
||||||
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
|
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
|
||||||
|
|
||||||
```
|
```
|
||||||
cd ios
|
cd ios
|
||||||
scripts/build-ipa.sh # writes build/Stemify.ipa
|
scripts/build-ipa.sh # writes build/stemify-<version>.ipa
|
||||||
```
|
```
|
||||||
|
|
||||||
Sideload `build/Stemify.ipa` with Sideloadly or AltStore, which sign it with your Apple ID. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
|
Sideload the IPA with Sideloadly or AltStore, which sign it with your Apple ID.
|
||||||
|
|
||||||
|
To get updates in AltStore, SideStore or Feather, add this source:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json
|
||||||
|
```
|
||||||
|
|
||||||
|
After uploading a new release (tag `ios-v<version>`, asset `stemify-<version>.ipa`), run `ios/scripts/make-source.py` to regenerate `ios/source.json` and commit it. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
|
||||||
|
|
||||||
CarPlay and Siri need the `carplay-audio` and `siri` entitlements. A free Apple ID can't sign those, so on a free sideload the app works on the phone but won't show up in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team.
|
CarPlay and Siri need the `carplay-audio` and `siri` entitlements. A free Apple ID can't sign those, so on a free sideload the app works on the phone but won't show up in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team.
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ final class Settings: @unchecked Sendable {
|
|||||||
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
|
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
|
||||||
/// Downloads page order: 0 download order, 1 artist, 2 release date.
|
/// Downloads page order: 0 download order, 1 artist, 2 release date.
|
||||||
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
|
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
|
||||||
|
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
|
||||||
|
/// How far down Soft karaoke puts the vocals, in dB.
|
||||||
|
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||||
var masterVolume: Float {
|
var masterVolume: Float {
|
||||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LyricLine: Hashable {
|
||||||
|
let ms: Int64
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lyrics {
|
||||||
|
var lines: [LyricLine] = []
|
||||||
|
/// False for plain lyrics, which have no timings (every [ms] is 0).
|
||||||
|
var synced = false
|
||||||
|
var instrumental = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track,
|
||||||
|
/// misses only until the app restarts.
|
||||||
|
actor LyricsSource {
|
||||||
|
static let shared = LyricsSource()
|
||||||
|
|
||||||
|
private struct Record: Codable {
|
||||||
|
var syncedLyrics: String?
|
||||||
|
var plainLyrics: String?
|
||||||
|
var instrumental: Bool?
|
||||||
|
var duration: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let dir: URL
|
||||||
|
private var misses: Set<String> = []
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nil when LRCLIB has nothing for this song or couldn't be reached.
|
||||||
|
func lyrics(_ t: Track) async -> Lyrics? {
|
||||||
|
let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json")
|
||||||
|
if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) }
|
||||||
|
if misses.contains(t.ratingKey) { return nil }
|
||||||
|
let secs = Double(t.durationMs) / 1000
|
||||||
|
var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))])
|
||||||
|
.flatMap { try? JSONDecoder().decode(Record.self, from: $0) }
|
||||||
|
if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]),
|
||||||
|
let found = try? JSONDecoder().decode([Record].self, from: data) {
|
||||||
|
// Closest length first, so a live or extended version doesn't win.
|
||||||
|
let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 }
|
||||||
|
rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first
|
||||||
|
}
|
||||||
|
guard let rec else {
|
||||||
|
misses.insert(t.ratingKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) }
|
||||||
|
return Self.parse(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func get(_ path: String, _ query: [String: String]) async -> Data? {
|
||||||
|
var c = URLComponents(string: "https://lrclib.net/api/\(path)")!
|
||||||
|
c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||||
|
var req = URLRequest(url: c.url!, timeoutInterval: 10)
|
||||||
|
req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent")
|
||||||
|
guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/#
|
||||||
|
|
||||||
|
/// LRC: each line starts with one or more [mm:ss.xx] stamps.
|
||||||
|
private static func parse(_ r: Record) -> Lyrics {
|
||||||
|
if let lrc = r.syncedLyrics, !lrc.isEmpty {
|
||||||
|
var lines: [LyricLine] = []
|
||||||
|
for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
let stamps = raw.matches(of: stamp)
|
||||||
|
guard let last = stamps.last else { continue }
|
||||||
|
let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces)
|
||||||
|
for m in stamps {
|
||||||
|
let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000)
|
||||||
|
lines.append(LyricLine(ms: ms, text: text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true)
|
||||||
|
}
|
||||||
|
let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) }
|
||||||
|
return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ enum Stems {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off }
|
||||||
|
|
||||||
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
||||||
struct StemSetting: Codable, Hashable {
|
struct StemSetting: Codable, Hashable {
|
||||||
static let unity: Float = 0.8
|
static let unity: Float = 0.8
|
||||||
@@ -106,6 +108,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
@ObservationIgnored private let eq: EqController
|
@ObservationIgnored private let eq: EqController
|
||||||
@ObservationIgnored private(set) var engine: StemEngine!
|
@ObservationIgnored private(set) var engine: StemEngine!
|
||||||
@ObservationIgnored private let queueFile: URL
|
@ObservationIgnored private let queueFile: URL
|
||||||
|
@ObservationIgnored private let songMixFile: URL
|
||||||
|
|
||||||
private(set) var queue: [QueueItem] = []
|
private(set) var queue: [QueueItem] = []
|
||||||
private(set) var index = -1
|
private(set) var index = -1
|
||||||
@@ -121,6 +124,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
private(set) var waveform: Waveform?
|
private(set) var waveform: Waveform?
|
||||||
private(set) var durationMs: Int64 = 0
|
private(set) var durationMs: Int64 = 0
|
||||||
private(set) var message: AppMessage?
|
private(set) var message: AppMessage?
|
||||||
|
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
|
||||||
|
private(set) var songMixes: [String: [String: StemSetting]] = [:]
|
||||||
|
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
|
||||||
|
private(set) var karaoke = false
|
||||||
|
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||||
|
|
||||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||||
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
||||||
@@ -148,6 +156,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.eq = eq
|
self.eq = eq
|
||||||
queueFile = dir.appendingPathComponent("queue.json")
|
queueFile = dir.appendingPathComponent("queue.json")
|
||||||
|
songMixFile = dir.appendingPathComponent("song-mixes.json")
|
||||||
|
songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:]
|
||||||
master = settings.masterVolume
|
master = settings.masterVolume
|
||||||
engine = StemEngine(listener: self)
|
engine = StemEngine(listener: self)
|
||||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||||
@@ -405,6 +415,61 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
pushMix()
|
pushMix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
||||||
|
private func startMix(_ t: Track) -> [String: StemSetting] {
|
||||||
|
if karaoke { return karaokeMix }
|
||||||
|
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||||
|
private var karaokeMix: [String: StemSetting] {
|
||||||
|
switch karaokeVocals {
|
||||||
|
case .On: return [:]
|
||||||
|
case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))]
|
||||||
|
case .Off: return ["vocals": StemSetting(muted: true)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off }
|
||||||
|
|
||||||
|
func setKaraokeVocals(_ v: KaraokeVocals) {
|
||||||
|
settings.karaokeVocals = v.rawValue
|
||||||
|
if karaoke { applyMix(karaokeMix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setKaraokeSoftDb(_ db: Int) {
|
||||||
|
settings.karaokeSoftDb = db
|
||||||
|
if karaoke && karaokeVocals == .Soft { applyMix(karaokeMix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
|
||||||
|
func setKaraoke(_ on: Bool) {
|
||||||
|
guard on != karaoke else { return }
|
||||||
|
karaoke = on
|
||||||
|
if on {
|
||||||
|
preKaraokeMix = mix
|
||||||
|
applyMix(karaokeMix)
|
||||||
|
} else {
|
||||||
|
if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||||
|
preKaraokeMix = nil
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } }
|
||||||
|
|
||||||
|
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
|
||||||
|
func forgetSongMix() { setSongMix(nil) }
|
||||||
|
|
||||||
|
private func setSongMix(_ m: [String: StemSetting]?) {
|
||||||
|
guard let key = current?.track.ratingKey else { return }
|
||||||
|
songMixes[key] = m
|
||||||
|
let all = songMixes, file = songMixFile
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
||||||
var s = mix[name] ?? StemSetting()
|
var s = mix[name] ?? StemSetting()
|
||||||
f(&s)
|
f(&s)
|
||||||
@@ -462,7 +527,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
if play && !activateSession() { return }
|
if play && !activateSession() { return }
|
||||||
playWhenReady = play
|
playWhenReady = play
|
||||||
buffering = true
|
buffering = true
|
||||||
if !settings.keepStemMix { mix = [:] }
|
mix = startMix(item.track)
|
||||||
changed()
|
changed()
|
||||||
save()
|
save()
|
||||||
loadTask = Task { @MainActor [weak self] in
|
loadTask = Task { @MainActor [weak self] in
|
||||||
@@ -635,7 +700,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
guard let item = current else { return }
|
guard let item = current else { return }
|
||||||
durationMs = item.track.durationMs
|
durationMs = item.track.durationMs
|
||||||
waveform = nil
|
waveform = nil
|
||||||
if !settings.keepStemMix { mix = [:] }
|
mix = startMix(item.track)
|
||||||
source = currentEntry != nil ? .Stems : .Original
|
source = currentEntry != nil ? .Stems : .Original
|
||||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||||
pushMix()
|
pushMix()
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ final class PlexClient: @unchecked Sendable {
|
|||||||
|
|
||||||
// ---- library ------------------------------------------------------------------
|
// ---- library ------------------------------------------------------------------
|
||||||
|
|
||||||
func artists() async throws -> [Artist] {
|
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
|
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||||
@@ -126,12 +126,17 @@ final class PlexClient: @unchecked Sendable {
|
|||||||
.metadata.map { $0.toAlbum() }
|
.metadata.map { $0.toAlbum() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One page of every track, sorted by title. Returns the page and the library's track count.
|
/// One page of every track. Returns the page and the library's track count.
|
||||||
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
|
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
|
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
|
||||||
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Random tracks from the whole library, for shuffling it without loading every page.
|
||||||
|
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||||
|
try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "random")], start: 0, size: count).metadata.map { $0.toTrack() }
|
||||||
|
}
|
||||||
|
|
||||||
func album(_ key: String) async throws -> Album? {
|
func album(_ key: String) async throws -> Album? {
|
||||||
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
||||||
}
|
}
|
||||||
@@ -185,8 +190,8 @@ final class PlexClient: @unchecked Sendable {
|
|||||||
|
|
||||||
// ---- playlists ----------------------------------------------------------------
|
// ---- playlists ----------------------------------------------------------------
|
||||||
|
|
||||||
func playlists() async throws -> [Playlist] {
|
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
|
||||||
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() }
|
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||||
|
|||||||
@@ -14,15 +14,12 @@ struct RootView: View {
|
|||||||
root(t).miniPlayerRoom()
|
root(t).miniPlayerRoom()
|
||||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||||
}
|
}
|
||||||
// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
.modifier(FloatingMiniPlayer())
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
|
|
||||||
}
|
|
||||||
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
|
|
||||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||||
.tag(t)
|
.tag(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(GlassMiniPlayerHost())
|
||||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||||
NowPlayingScreen()
|
NowPlayingScreen()
|
||||||
.overlayHost(ui.overlays, level: 1)
|
.overlayHost(ui.overlays, level: 1)
|
||||||
@@ -72,11 +69,43 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS 26.1 and up put the mini player in the tab bar's glass accessory; older ones float a card over each tab.
|
||||||
|
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
|
||||||
|
|
||||||
|
private struct GlassMiniPlayerHost: ViewModifier {
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 26.1, *) {
|
||||||
|
content
|
||||||
|
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
|
||||||
|
.tabBarMinimizeBehavior(.onScrollDown)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
||||||
|
private struct FloatingMiniPlayer: ViewModifier {
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if glassAccessory {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
|
||||||
|
}
|
||||||
|
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct MiniPlayerRoom: ViewModifier {
|
private struct MiniPlayerRoom: ViewModifier {
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
if g.playback.current != nil { Color.clear.frame(height: 66) }
|
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,131 @@ private struct LibraryRow: View {
|
|||||||
|
|
||||||
// ---- artists --------------------------------------------------------------------------------
|
// ---- artists --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- sorting ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One way to sort a list. `key` is Plex's sort key; `desc` is the direction it starts in.
|
||||||
|
struct SortOption {
|
||||||
|
let title: String
|
||||||
|
let key: String
|
||||||
|
var desc = false
|
||||||
|
var up = "Ascending"
|
||||||
|
var down = "Descending"
|
||||||
|
|
||||||
|
/// Plex reverses a compound key by its first field.
|
||||||
|
func param(_ reversed: Bool) -> String {
|
||||||
|
guard desc != reversed else { return key }
|
||||||
|
var fields = key.split(separator: ",").map(String.init)
|
||||||
|
fields[0] += ":desc"
|
||||||
|
return fields.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let AZ = (up: "A to Z", down: "Z to A")
|
||||||
|
private let NEW = (up: "Oldest First", down: "Newest First")
|
||||||
|
private let MOST = (up: "Fewest First", down: "Most First")
|
||||||
|
|
||||||
|
let ARTIST_SORTS = [
|
||||||
|
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||||
|
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let ALBUM_SORTS = [
|
||||||
|
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Artist", key: "artist.titleSort,year", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Year", key: "year", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Release Date", key: "originallyAvailableAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||||
|
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let SONG_SORTS = [
|
||||||
|
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Artist", key: "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Album", key: "album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||||
|
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||||
|
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let PLAYLIST_SORTS = [
|
||||||
|
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||||
|
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Last Updated", key: "updatedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||||
|
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||||
|
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// A screen's sort choice, kept across launches under `sort.<name>`.
|
||||||
|
struct SortState: DynamicProperty {
|
||||||
|
let options: [SortOption]
|
||||||
|
@AppStorage private var index: Int
|
||||||
|
@AppStorage private var reversed: Bool
|
||||||
|
|
||||||
|
init(_ name: String, _ options: [SortOption]) {
|
||||||
|
self.options = options
|
||||||
|
_index = AppStorage(wrappedValue: 0, "sort.\(name)")
|
||||||
|
_reversed = AppStorage(wrappedValue: false, "sort.\(name).rev")
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Int { options.indices.contains(index) ? index : 0 }
|
||||||
|
var option: SortOption { options[current] }
|
||||||
|
var param: String { option.param(reversed) }
|
||||||
|
/// The default order (first option, its own direction), where a letter index makes sense.
|
||||||
|
var isDefault: Bool { current == 0 && !reversed }
|
||||||
|
|
||||||
|
/// Picking the current field again flips it, like Plexamp.
|
||||||
|
func pick(_ i: Int) {
|
||||||
|
if i == current { reversed.toggle() } else { index = i; reversed = false }
|
||||||
|
}
|
||||||
|
func setDescending(_ d: Bool) { reversed = d != option.desc }
|
||||||
|
var descending: Bool { option.desc != reversed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toolbar menu: the sort fields, then the direction for the chosen one.
|
||||||
|
struct SortMenu: View {
|
||||||
|
let sort: SortState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Section("Sort By") {
|
||||||
|
ForEach(sort.options.indices, id: \.self) { i in
|
||||||
|
Button { sort.pick(i) } label: {
|
||||||
|
if i == sort.current {
|
||||||
|
Label(sort.options[i].title, systemImage: sort.descending ? "chevron.down" : "chevron.up")
|
||||||
|
} else {
|
||||||
|
Text(sort.options[i].title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button { sort.setDescending(false) } label: {
|
||||||
|
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
|
||||||
|
}
|
||||||
|
Button { sort.setDescending(true) } label: {
|
||||||
|
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- artists ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
struct ArtistsScreen: View {
|
struct ArtistsScreen: View {
|
||||||
@Environment(Ui.self) private var ui
|
@Environment(Ui.self) private var ui
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
|
private let sort = SortState("artists", ARTIST_SORTS)
|
||||||
@State private var artists: LoadState<[Artist]> = .loading
|
@State private var artists: LoadState<[Artist]> = .loading
|
||||||
@State private var reload = 0
|
@State private var reload = 0
|
||||||
|
|
||||||
@@ -135,14 +257,15 @@ struct ArtistsScreen: View {
|
|||||||
}
|
}
|
||||||
.background(p.bg)
|
.background(p.bg)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
if let list = artists.value, list.count > 30 {
|
if sort.isDefault, let list = artists.value, list.count > 30 {
|
||||||
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Artists")
|
.navigationTitle("Artists")
|
||||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||||
|
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,11 +318,9 @@ private struct SectionIndex: View {
|
|||||||
|
|
||||||
// ---- albums ---------------------------------------------------------------------------------
|
// ---- albums ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
|
|
||||||
|
|
||||||
struct AlbumsScreen: View {
|
struct AlbumsScreen: View {
|
||||||
@Environment(Ui.self) private var ui
|
@Environment(Ui.self) private var ui
|
||||||
@State private var sort = 0
|
private let sort = SortState("albums", ALBUM_SORTS)
|
||||||
@State private var albums: LoadState<[Album]> = .loading
|
@State private var albums: LoadState<[Album]> = .loading
|
||||||
@State private var reload = 0
|
@State private var reload = 0
|
||||||
|
|
||||||
@@ -210,16 +331,10 @@ struct AlbumsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||||
Button("Sort") {
|
|
||||||
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
|
|
||||||
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||||
}
|
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
|
|
||||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,8 +348,19 @@ struct SongsScreen: View {
|
|||||||
@State private var total = -1
|
@State private var total = -1
|
||||||
@State private var loading = false
|
@State private var loading = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
|
||||||
|
@State private var gen = 0
|
||||||
|
private let sort = SortState("songs", SONG_SORTS)
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
/// Shuffles the whole library, not just the pages loaded so far.
|
||||||
|
private func shuffleAll() {
|
||||||
|
Task {
|
||||||
|
do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) }
|
||||||
|
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Page(title: "Songs") {
|
Page(title: "Songs") {
|
||||||
if !tracks.isEmpty {
|
if !tracks.isEmpty {
|
||||||
@@ -242,7 +368,7 @@ struct SongsScreen: View {
|
|||||||
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
||||||
shuffle: { g.playback.playTracks(Array(tracks.prefix(1000)), shuffle: true) })
|
shuffle: shuffleAll)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
|
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
|
||||||
@@ -256,17 +382,25 @@ struct SongsScreen: View {
|
|||||||
if loading { LoadingRow() }
|
if loading { LoadingRow() }
|
||||||
if let error { ErrorRow(message: error) { loadMore() } }
|
if let error { ErrorRow(message: error) { loadMore() } }
|
||||||
}
|
}
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||||
.task { if tracks.isEmpty { loadMore() } }
|
.task { if tracks.isEmpty { loadMore() } }
|
||||||
|
.onChange(of: sort.param) {
|
||||||
|
gen += 1; tracks = []; total = -1; error = nil; loading = false
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMore() {
|
private func loadMore() {
|
||||||
if loading || (total >= 0 && tracks.count >= total) { return }
|
if loading || (total >= 0 && tracks.count >= total) { return }
|
||||||
loading = true
|
loading = true
|
||||||
|
let mine = gen, order = sort.param
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
|
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
|
||||||
|
guard mine == gen else { return }
|
||||||
tracks += page; total = n; error = nil
|
tracks += page; total = n; error = nil
|
||||||
} catch {
|
} catch {
|
||||||
|
guard mine == gen else { return }
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum NpPanel { case Art, Mixer, Queue }
|
enum NpPanel { case Art, Mixer, Queue, Lyrics }
|
||||||
|
|
||||||
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
||||||
switch d.kind {
|
switch d.kind {
|
||||||
@@ -105,6 +105,7 @@ struct NowPlayingScreen: View {
|
|||||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
||||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
||||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
||||||
|
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
@@ -123,6 +124,7 @@ struct NowPlayingScreen: View {
|
|||||||
.tint(c.label)
|
.tint(c.label)
|
||||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
|
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
|
||||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||||
tint = await albumColor(thumb, dark: base.dark)
|
tint = await albumColor(thumb, dark: base.dark)
|
||||||
@@ -172,6 +174,7 @@ struct NowPlayingScreen: View {
|
|||||||
HStack {
|
HStack {
|
||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||||
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||||
}
|
}
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
@@ -180,6 +183,16 @@ struct NowPlayingScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
|
/// Karaoke: vocals down and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||||
|
private func karaoke() {
|
||||||
|
let pm = g.playback
|
||||||
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
|
||||||
|
if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return }
|
||||||
|
pm.setKaraoke(!pm.karaoke)
|
||||||
|
panel = pm.karaoke ? .Lyrics : .Art
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func show(_ to: NpPanel) {
|
private func show(_ to: NpPanel) {
|
||||||
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 }
|
||||||
}
|
}
|
||||||
@@ -490,6 +503,7 @@ private final class Meters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct MixerPanel: View {
|
private struct MixerPanel: View {
|
||||||
|
@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()
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
@@ -510,6 +524,20 @@ private struct MixerPanel: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
let current = MixPresets.normalized(pm.mix)
|
let current = MixPresets.normalized(pm.mix)
|
||||||
|
let saved = pm.songMix.map(MixPresets.normalized)
|
||||||
|
let isSaved = saved == current
|
||||||
|
PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) {
|
||||||
|
if isSaved {
|
||||||
|
pm.forgetSongMix()
|
||||||
|
ui.overlays.hud("Removed this song's mix", "bookmark.slash")
|
||||||
|
} else {
|
||||||
|
pm.saveSongMix()
|
||||||
|
ui.overlays.hud("Mix saved for this song", "bookmark.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let saved, !isSaved {
|
||||||
|
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
|
||||||
|
}
|
||||||
ForEach(MixPresets.all) { preset in
|
ForEach(MixPresets.all) { preset in
|
||||||
if let target = preset.build(names) {
|
if let target = preset.build(names) {
|
||||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||||
@@ -549,12 +577,17 @@ private struct MixerPanel: View {
|
|||||||
/// [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
|
||||||
|
var icon: String? = nil
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
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.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
HStack(spacing: 5) {
|
||||||
|
if let icon { Image(systemName: icon) }
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
.font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
||||||
.padding(.horizontal, 14).frame(height: 32)
|
.padding(.horizontal, 14).frame(height: 32)
|
||||||
.background(selected ? p.label : p.fill, in: Capsule())
|
.background(selected ? p.label : p.fill, in: Capsule())
|
||||||
}
|
}
|
||||||
@@ -761,8 +794,14 @@ struct MiniPlayer: View {
|
|||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if let t = g.playback.current?.track {
|
||||||
|
if #available(iOS 26.1, *) { GlassMiniPlayer(track: t) } else { card(t) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before iOS 26: a card floating over each tab.
|
||||||
|
@ViewBuilder private func card(_ t: Track) -> some View {
|
||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
if let t = pm.current?.track {
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
@@ -770,19 +809,7 @@ struct MiniPlayer: View {
|
|||||||
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
Button(action: pm.togglePlay) {
|
MiniTransport(color: p.label, next: true)
|
||||||
ZStack {
|
|
||||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
|
||||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
|
|
||||||
}
|
|
||||||
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
|
||||||
.foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(PressFade())
|
|
||||||
Button(action: pm.next) {
|
|
||||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(PressFade())
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 8).padding(.trailing, 6)
|
.padding(.leading, 8).padding(.trailing, 6)
|
||||||
.frame(height: 60)
|
.frame(height: 60)
|
||||||
@@ -813,4 +840,134 @@ struct MiniPlayer: View {
|
|||||||
.actionsMenu(ui.menu.track(t))
|
.actionsMenu(ui.menu.track(t))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS 26: lives in the tab bar's Liquid Glass accessory, like Music. Shrinks to title and play when the bar collapses.
|
||||||
|
@available(iOS 26.1, *)
|
||||||
|
private struct GlassMiniPlayer: View {
|
||||||
|
let track: Track
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||||||
|
private var inline: Bool { placement == .inline }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Artwork(thumb: track.thumb, size: inline ? 28 : 34, corner: 6)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(track.title).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||||
|
if !inline { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||||
|
}
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
MiniTransport(color: .primary, next: !inline)
|
||||||
|
}
|
||||||
|
.padding(.leading, 10).padding(.trailing, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { ui.openNowPlaying() }
|
||||||
|
.actionsMenu(ui.menu.track(track))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play/pause (spinner while buffering) and next, for the mini player.
|
||||||
|
private struct MiniTransport: View {
|
||||||
|
let color: Color
|
||||||
|
let next: Bool
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let pm = g.playback
|
||||||
|
Button(action: pm.togglePlay) {
|
||||||
|
ZStack {
|
||||||
|
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||||
|
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
|
||||||
|
}
|
||||||
|
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
||||||
|
.foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PressFade())
|
||||||
|
if next {
|
||||||
|
Button(action: pm.next) {
|
||||||
|
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PressFade())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
|
||||||
|
private struct LyricsPanel: View {
|
||||||
|
let track: Track
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var lyrics: Lyrics?
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var at = -1
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Soft leaves the singer in quietly as a guide.
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) {
|
||||||
|
ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28).padding(.top, 8)
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.task(id: track.ratingKey) { await follow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var content: some View {
|
||||||
|
Group {
|
||||||
|
if loading {
|
||||||
|
ProgressView()
|
||||||
|
} else if let l = lyrics, !l.lines.isEmpty {
|
||||||
|
lines(l)
|
||||||
|
} else {
|
||||||
|
Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found")
|
||||||
|
.font(.title3.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func follow() async {
|
||||||
|
loading = true
|
||||||
|
at = -1
|
||||||
|
lyrics = await LyricsSource.shared.lyrics(track)
|
||||||
|
loading = false
|
||||||
|
guard let l = lyrics, l.synced else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
// A little early, so the line lights up as it's sung rather than after.
|
||||||
|
let pos = g.playback.positionMs() + 250
|
||||||
|
let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1
|
||||||
|
if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } }
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lines(_ l: Lyrics) -> some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: l.synced ? 18 : 6) {
|
||||||
|
ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in
|
||||||
|
Text(line.text.isEmpty ? "\u{266A}" : line.text)
|
||||||
|
.font(l.synced ? .title2.weight(.bold) : .body)
|
||||||
|
.foregroundStyle(!l.synced || i == at ? p.label : p.label3)
|
||||||
|
.scaleEffect(i == at ? 1.03 : 1, anchor: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { if l.synced { g.playback.seekTo(line.ms) } }
|
||||||
|
.id(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.vertical, l.synced ? 120 : 16)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.onChange(of: at) { _, i in
|
||||||
|
if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ struct PlaylistsScreen: View {
|
|||||||
@Environment(Ui.self) private var ui
|
@Environment(Ui.self) private var ui
|
||||||
@State private var lists: LoadState<[Playlist]> = .loading
|
@State private var lists: LoadState<[Playlist]> = .loading
|
||||||
@State private var reload = 0
|
@State private var reload = 0
|
||||||
|
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,12 +22,13 @@ struct PlaylistsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: "\(g.settings.musicSection)|\(reload)") {
|
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||||
await LoadState.load($lists) { try await g.plex.playlists() }
|
await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ private struct ToggleCell: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How quiet karaoke's Soft vocals are.
|
||||||
|
private struct SoftVocalsCell: View {
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
var body: some View {
|
||||||
|
let db = g.settings.karaokeSoftDb
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
SettingsIcon(symbol: "music.mic", bg: 0xFA2D48)
|
||||||
|
Text("Karaoke Soft Vocals").foregroundStyle(p.label)
|
||||||
|
Spacer()
|
||||||
|
Text("\(db) dB").monospacedDigit().foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) {
|
||||||
|
Text("Karaoke Soft Vocals")
|
||||||
|
} minimumValueLabel: {
|
||||||
|
Text("Quieter").font(.caption).foregroundStyle(p.label2)
|
||||||
|
} maximumValueLabel: {
|
||||||
|
Text("Louder").font(.caption).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.themedRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A tappable row in the accent (or red), optionally centred.
|
/// A tappable row in the accent (or red), optionally centred.
|
||||||
private struct ActionCell: View {
|
private struct ActionCell: View {
|
||||||
let title: String
|
let title: String
|
||||||
@@ -115,6 +141,7 @@ struct SettingsScreen: View {
|
|||||||
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
||||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||||
|
SoftVocalsCell()
|
||||||
} header: { Header(text: "Sound") }
|
} header: { Header(text: "Sound") }
|
||||||
Section {
|
Section {
|
||||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||||
|
|||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
name: Stemify
|
name: Stemify
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.stemify
|
bundleIdPrefix: org.cdnutter
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "17.0"
|
iOS: "17.0"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.1"
|
MARKETING_VERSION: "1.0.7"
|
||||||
CURRENT_PROJECT_VERSION: "2"
|
CURRENT_PROJECT_VERSION: "8"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
@@ -15,7 +15,7 @@ targets:
|
|||||||
sources: [Stemify]
|
sources: [Stemify]
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.stemify.mobile
|
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify
|
||||||
TARGETED_DEVICE_FAMILY: "1"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
|
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Builds an unsigned Stemify.ipa on a Mac, for re-signing with Sideloadly/AltStore.
|
# Builds an unsigned build/stemify-<version>.ipa on a Mac, for re-signing with Sideloadly/AltStore.
|
||||||
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -7,14 +7,17 @@ export PATH=/opt/homebrew/bin:$PATH
|
|||||||
OUT=build; APP=$OUT/Payload/Stemify.app
|
OUT=build; APP=$OUT/Payload/Stemify.app
|
||||||
rm -rf $OUT && mkdir -p $APP
|
rm -rf $OUT && mkdir -p $APP
|
||||||
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
|
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
|
||||||
|
# Version and build number come from project.yml.
|
||||||
|
VERSION=$(sed -nE 's/^ *MARKETING_VERSION: "(.*)"/\1/p' project.yml)
|
||||||
|
BUILD=$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: "(.*)"/\1/p' project.yml)
|
||||||
|
|
||||||
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \
|
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \
|
||||||
-module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify
|
-module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify
|
||||||
|
|
||||||
# Info.plist: xcodegen's generated one with the build settings filled in.
|
# Info.plist: xcodegen's generated one with the build settings filled in.
|
||||||
xcodegen generate -q
|
xcodegen generate -q
|
||||||
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/com.stemify.mobile/' \
|
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/org.cdnutter.stemify/' \
|
||||||
-e 's/$(PRODUCT_NAME)/Stemify/; s/$(MARKETING_VERSION)/1.0.1/; s/$(CURRENT_PROJECT_VERSION)/2/' \
|
-e "s/\$(PRODUCT_NAME)/Stemify/; s/\$(MARKETING_VERSION)/$VERSION/; s/\$(CURRENT_PROJECT_VERSION)/$BUILD/" \
|
||||||
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
|
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
|
||||||
P=$APP/Info.plist
|
P=$APP/Info.plist
|
||||||
plutil -insert MinimumOSVersion -string 17.0 $P
|
plutil -insert MinimumOSVersion -string 17.0 $P
|
||||||
@@ -31,5 +34,5 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
|
|||||||
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
||||||
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
||||||
|
|
||||||
(cd $OUT && zip -qry Stemify.ipa Payload)
|
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
||||||
echo "$OUT/Stemify.ipa"
|
echo "$OUT/stemify-$VERSION.ipa"
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Writes ios/source.json, an AltStore source (SideStore and Feather read it too), from the
|
||||||
|
ios-v* releases on Gitea. Only releases with a stemify-<version>.ipa are listed, since older
|
||||||
|
ones used a different bundle ID. Set GITEA_TOKEN if the repo is private."""
|
||||||
|
import json, os, re, urllib.request
|
||||||
|
|
||||||
|
REPO = "https://git.iamchrisama.com/cdnutter/stemify"
|
||||||
|
API = "https://git.iamchrisama.com/api/v1/repos/cdnutter/stemify"
|
||||||
|
RAW = f"{REPO}/raw/branch/main"
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
if os.environ.get("GITEA_TOKEN"):
|
||||||
|
req.add_header("Authorization", "token " + os.environ["GITEA_TOKEN"])
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
yml = open(os.path.join(HERE, "..", "project.yml")).read()
|
||||||
|
# Build numbers: older ones from the last source.json, the current one from project.yml.
|
||||||
|
builds = {}
|
||||||
|
try:
|
||||||
|
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
|
||||||
|
builds[v["version"]] = v["buildVersion"]
|
||||||
|
except (OSError, ValueError, KeyError, IndexError):
|
||||||
|
pass
|
||||||
|
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
|
||||||
|
builds[m[0][1]] = m[1][1]
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
for rel in get(API + "/releases?limit=50"):
|
||||||
|
v = rel["tag_name"].removeprefix("ios-v")
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
versions.append({
|
||||||
|
"version": v,
|
||||||
|
"buildVersion": builds.get(v, v),
|
||||||
|
"date": rel["published_at"],
|
||||||
|
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
|
||||||
|
"downloadURL": ipa["browser_download_url"],
|
||||||
|
"size": ipa["size"],
|
||||||
|
"minOSVersion": "17.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
source = {
|
||||||
|
"name": "Stemify",
|
||||||
|
"identifier": "org.cdnutter.stemify.source",
|
||||||
|
"sourceURL": f"{RAW}/ios/source.json",
|
||||||
|
"iconURL": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"website": REPO,
|
||||||
|
"apps": [{
|
||||||
|
"name": "Stemify",
|
||||||
|
"bundleIdentifier": "org.cdnutter.stemify",
|
||||||
|
"developerName": "cdnutter",
|
||||||
|
"subtitle": "Plex music player with live stem mixing.",
|
||||||
|
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. "
|
||||||
|
"Works offline with downloads, and in the car with CarPlay.",
|
||||||
|
"iconURL": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"tintColor": "#FA2D48",
|
||||||
|
"category": "entertainment",
|
||||||
|
"versions": versions,
|
||||||
|
"appPermissions": {
|
||||||
|
"entitlements": ["com.apple.developer.carplay-audio", "com.apple.developer.siri"],
|
||||||
|
"privacy": {
|
||||||
|
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
|
||||||
|
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
"news": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(os.path.join(HERE, "..", "source.json"), "w") as f:
|
||||||
|
json.dump(source, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"source.json: {len(versions)} version(s)")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "Stemify",
|
||||||
|
"identifier": "org.cdnutter.stemify.source",
|
||||||
|
"sourceURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json",
|
||||||
|
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"website": "https://git.iamchrisama.com/cdnutter/stemify",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "Stemify",
|
||||||
|
"bundleIdentifier": "org.cdnutter.stemify",
|
||||||
|
"developerName": "cdnutter",
|
||||||
|
"subtitle": "Plex music player with live stem mixing.",
|
||||||
|
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. Works offline with downloads, and in the car with CarPlay.",
|
||||||
|
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"tintColor": "#FA2D48",
|
||||||
|
"category": "entertainment",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.6",
|
||||||
|
"buildVersion": "7",
|
||||||
|
"date": "2026-10-20T09:31:01-07:00",
|
||||||
|
"localizedDescription": "- Karaoke vocals option on the lyrics panel: On (full vocals), Soft (vocals about 12 dB down as a guide), Off (no vocals). Remembered between songs and launches.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.6/stemify-1.0.6.ipa",
|
||||||
|
"size": 1375443,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.5",
|
||||||
|
"buildVersion": "6",
|
||||||
|
"date": "2026-10-20T09:23:39-07:00",
|
||||||
|
"localizedDescription": "- Sort menu on Artists, Albums, Songs and Playlists (Name, Date Added, Last Played, Plays, Rating, Year, Duration and more). Tap the current field again to flip direction (A to Z / Z to A, newest / oldest).\n- Each screen remembers its sort.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.5/stemify-1.0.5.ipa",
|
||||||
|
"size": 1370686,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.4",
|
||||||
|
"buildVersion": "5",
|
||||||
|
"date": "2026-10-20T09:18:33-07:00",
|
||||||
|
"localizedDescription": "Save a stem mix for a song (Save chip in the mixer); it comes back every time that song plays. New mic button for karaoke mode: vocals muted on every song plus synced lyrics from LRCLIB, tap a line to jump to it.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.4/stemify-1.0.4.ipa",
|
||||||
|
"size": 1355610,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.3",
|
||||||
|
"buildVersion": "4",
|
||||||
|
"date": "2026-10-20T09:08:18-07:00",
|
||||||
|
"localizedDescription": "Bundle ID is now org.cdnutter.stemify (delete the old app first; settings won't carry over). IPA is named stemify-<version>.ipa. Adds an AltStore/SideStore/Feather source.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa",
|
||||||
|
"size": 1319672,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appPermissions": {
|
||||||
|
"entitlements": [
|
||||||
|
"com.apple.developer.carplay-audio",
|
||||||
|
"com.apple.developer.siri"
|
||||||
|
],
|
||||||
|
"privacy": {
|
||||||
|
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
|
||||||
|
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"news": []
|
||||||
|
}
|
||||||
Reference in new issue
Block a user