Compare commits
12
Commits
ios-v1.0.2
...
ios-v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cf62cb07 | ||
|
|
6f634522e5 | ||
|
|
d52a3e257f | ||
|
|
e98d595365 | ||
|
|
41b8171df2 | ||
|
|
8e5ebb70b1 | ||
|
|
65bc69a7fe | ||
|
|
d149af336a | ||
|
|
3f7fd76762 | ||
|
|
bea3f8f5a2 | ||
|
|
e0184f540b | ||
|
|
1123f460b0 |
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.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ final class AppGraph {
|
|||||||
let cache: MediaCache
|
let cache: MediaCache
|
||||||
let eq: EqController
|
let eq: EqController
|
||||||
let playback: PlaybackManager
|
let playback: PlaybackManager
|
||||||
|
let stemPlaylists: StemPlaylists
|
||||||
let downloads: Downloads
|
let downloads: Downloads
|
||||||
let coverage: StemCoverage
|
let coverage: StemCoverage
|
||||||
let nowPlaying: NowPlaying
|
let nowPlaying: NowPlaying
|
||||||
@@ -59,6 +60,7 @@ final class AppGraph {
|
|||||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||||
|
|
||||||
eq = EqController(dir: support)
|
eq = EqController(dir: support)
|
||||||
|
stemPlaylists = StemPlaylists(dir: support)
|
||||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
|
|||||||
struct QueueItem: Codable, Hashable, Identifiable {
|
struct QueueItem: Codable, Hashable, Identifiable {
|
||||||
var uid: Int64
|
var uid: Int64
|
||||||
var track: Track
|
var track: Track
|
||||||
|
/// Set when the song came from a stem playlist; it plays with this mix instead of its saved one.
|
||||||
|
var mix: [String: StemSetting]?
|
||||||
var id: Int64 { uid }
|
var id: Int64 { uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +110,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 +126,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 +158,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 }
|
||||||
@@ -194,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
|
|
||||||
// ---- queue --------------------------------------------------------------------------
|
// ---- queue --------------------------------------------------------------------------
|
||||||
|
|
||||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
|
||||||
tracks.map { t in
|
tracks.enumerated().map { i, t in
|
||||||
defer { nextUid += 1 }
|
defer { nextUid += 1 }
|
||||||
return QueueItem(uid: nextUid, track: t)
|
return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
|
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
|
||||||
|
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||||
if tracks.isEmpty { return }
|
if tracks.isEmpty { return }
|
||||||
let items = items(tracks)
|
let items = items(tracks, mixes)
|
||||||
if doShuffle {
|
if doShuffle {
|
||||||
unshuffled = items
|
unshuffled = items
|
||||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||||
@@ -218,12 +231,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
|
||||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) }
|
||||||
|
|
||||||
private func insert(_ at: Int, _ tracks: [Track]) {
|
private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) {
|
||||||
if tracks.isEmpty { return }
|
if tracks.isEmpty { return }
|
||||||
let items = items(tracks)
|
let items = items(tracks, mixes)
|
||||||
var q = queue
|
var q = queue
|
||||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||||
queue = q
|
queue = q
|
||||||
@@ -405,6 +418,62 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
pushMix()
|
pushMix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
|
||||||
|
/// else cleared (or kept, per settings).
|
||||||
|
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
|
||||||
|
if karaoke { return karaokeMix }
|
||||||
|
return item.mix ?? songMixes[item.track.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 +531,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)
|
||||||
changed()
|
changed()
|
||||||
save()
|
save()
|
||||||
loadTask = Task { @MainActor [weak self] in
|
loadTask = Task { @MainActor [weak self] in
|
||||||
@@ -635,7 +704,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)
|
||||||
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()
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// One song in a stem playlist, saved with the mix it plays with.
|
||||||
|
struct StemPlaylistItem: Codable, Hashable, Identifiable {
|
||||||
|
var uid = UUID().uuidString
|
||||||
|
var track: Track
|
||||||
|
var mix: [String: StemSetting]
|
||||||
|
var id: String { uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StemPlaylist: Codable, Hashable, Identifiable {
|
||||||
|
var id = UUID().uuidString
|
||||||
|
var title: String
|
||||||
|
var items: [StemPlaylistItem] = []
|
||||||
|
var created = Date()
|
||||||
|
var updated = Date()
|
||||||
|
|
||||||
|
var tracks: [Track] { items.map(\.track) }
|
||||||
|
var mixes: [[String: StemSetting]] { items.map(\.mix) }
|
||||||
|
var thumb: String? { items.lazy.compactMap(\.track.thumb).first }
|
||||||
|
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…).
|
||||||
|
/// Plex playlists can't hold a mix, so these are separate.
|
||||||
|
@MainActor @Observable
|
||||||
|
final class StemPlaylists {
|
||||||
|
private(set) var lists: [StemPlaylist] = []
|
||||||
|
@ObservationIgnored private let file: URL
|
||||||
|
|
||||||
|
/// Every stem a song can have, for naming saved mixes without loading the song.
|
||||||
|
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||||
|
|
||||||
|
init(dir: URL) {
|
||||||
|
file = dir.appendingPathComponent("stem-playlists.json")
|
||||||
|
lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist {
|
||||||
|
let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) })
|
||||||
|
lists.insert(pl, at: 0)
|
||||||
|
save()
|
||||||
|
return pl
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) {
|
||||||
|
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
|
||||||
|
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
|
||||||
|
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
|
||||||
|
edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } }
|
||||||
|
}
|
||||||
|
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
|
||||||
|
|
||||||
|
func delete(_ id: String) {
|
||||||
|
lists.removeAll { $0.id == id }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one.
|
||||||
|
static func label(_ mix: [String: StemSetting]) -> String {
|
||||||
|
if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label }
|
||||||
|
if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" }
|
||||||
|
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||||
|
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
change(&lists[i])
|
||||||
|
lists[i].updated = Date()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let all = lists, file = file
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +126,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,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] {
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ extension PlexItem {
|
|||||||
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
||||||
|
|
||||||
func toPlaylist() -> Playlist {
|
func toPlaylist() -> Playlist {
|
||||||
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0,
|
Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
|
||||||
durationMs: duration ?? 0, smart: smart ?? false)
|
durationMs: duration ?? 0, smart: smart ?? false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ struct MediaMenu {
|
|||||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
||||||
}
|
}
|
||||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
||||||
|
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { addToStemPlaylist([t]) })
|
||||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||||
if allowGoTo, let k = t.albumKey {
|
if allowGoTo, let k = t.albumKey {
|
||||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||||
@@ -52,10 +53,49 @@ struct MediaMenu {
|
|||||||
|
|
||||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||||
let plex = g.plex
|
let plex = g.plex
|
||||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) }
|
||||||
|
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
|
||||||
confirmDownload: pl.trackCount > 50)
|
confirmDownload: pl.trackCount > 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] {
|
||||||
|
var a = [
|
||||||
|
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||||
|
g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
|
||||||
|
},
|
||||||
|
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||||
|
g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
|
||||||
|
if !missing.isEmpty {
|
||||||
|
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
|
||||||
|
}
|
||||||
|
if pl.items.isEmpty { a = [] }
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
|
||||||
|
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
|
||||||
|
})
|
||||||
|
return extra + a
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A song in a stem playlist: queue it with its mix, change the mix, or take it out.
|
||||||
|
func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] {
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
return [
|
||||||
|
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||||
|
g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
|
||||||
|
},
|
||||||
|
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||||
|
g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
|
||||||
|
},
|
||||||
|
SheetAction("Change Mix…", icon: "slider.horizontal.3") {
|
||||||
|
chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) }
|
||||||
|
},
|
||||||
|
SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// For songs already in hand (downloads with no Plex album behind them).
|
/// For songs already in hand (downloads with no Plex album behind them).
|
||||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||||
@@ -90,6 +130,7 @@ struct MediaMenu {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
||||||
|
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { withTracks(load) { addToStemPlaylist($0) } })
|
||||||
if !stemmed { a.append(stemNow(load)) }
|
if !stemmed { a.append(stemNow(load)) }
|
||||||
return a + extra
|
return a + extra
|
||||||
}
|
}
|
||||||
@@ -160,6 +201,66 @@ struct MediaMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
|
||||||
|
func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) {
|
||||||
|
let pm = g.playback
|
||||||
|
var acts: [SheetAction] = []
|
||||||
|
if tracks.count == 1, let t = tracks.first {
|
||||||
|
if pm.current?.track.ratingKey == t.ratingKey {
|
||||||
|
let m = pm.mix
|
||||||
|
acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) })
|
||||||
|
}
|
||||||
|
if let m = pm.songMixes[t.ratingKey] {
|
||||||
|
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in MixPresets.all {
|
||||||
|
guard let m = p.build(StemPlaylists.allStems) else { continue }
|
||||||
|
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
|
||||||
|
}
|
||||||
|
overlays.actions("Choose a Mix", acts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addToStemPlaylist(_ tracks: [Track]) {
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
overlays.actions("Add \(songs(tracks.count)) to Stem Playlist", [SheetAction("New Stem Playlist…") { newStemPlaylist(tracks) }] + lists.lists.map { pl in
|
||||||
|
SheetAction(pl.title) {
|
||||||
|
chooseMix(tracks) { m in
|
||||||
|
lists.add(pl.id, tracks: tracks, mix: m)
|
||||||
|
overlays.hud("Added to \(pl.title)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
|
||||||
|
if tracks.isEmpty {
|
||||||
|
onCreated(lists.create(name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chooseMix(tracks) { m in
|
||||||
|
onCreated(lists.create(name, tracks: tracks, mix: m))
|
||||||
|
overlays.hud("Created “\(name)”")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a stem playlist from a Plex one, every song with the same mix.
|
||||||
|
func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||||
|
let plex = g.plex
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
|
||||||
|
withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in
|
||||||
|
chooseMix(tracks) { m in
|
||||||
|
onCreated(lists.create(name, tracks: tracks, mix: m))
|
||||||
|
overlays.hud("Copied \(songs(tracks.count))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||||
let plex = g.plex
|
let plex = g.plex
|
||||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum Route: Hashable {
|
|||||||
case Artists, Albums, Songs, Playlists
|
case Artists, Albums, Songs, Playlists
|
||||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist)
|
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -68,6 +65,39 @@ struct RootView: View {
|
|||||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||||
|
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +106,7 @@ 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,6 +348,9 @@ 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.
|
/// Shuffles the whole library, not just the pages loaded so far.
|
||||||
@@ -264,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,143 @@ 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)
|
||||||
|
if g.playback.karaokeVocals == .Soft {
|
||||||
|
let db = g.settings.karaokeSoftDb
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
|
||||||
|
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.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) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,25 @@ import SwiftUI
|
|||||||
|
|
||||||
// ---- playlists ------------------------------------------------------------------------------
|
// ---- playlists ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
|
||||||
|
|
||||||
struct PlaylistsScreen: View {
|
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
|
||||||
|
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
|
||||||
|
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 {
|
||||||
Page(title: "Playlists") {
|
Page(title: "Playlists") {
|
||||||
|
Picker("Kind", selection: $kind) {
|
||||||
|
Text("Plex").tag(PlaylistKind.Plex)
|
||||||
|
Text("Stem").tag(PlaylistKind.Stem)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, MARGIN).padding(.bottom, 8)
|
||||||
|
if kind == .Plex {
|
||||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
Loaded(state: lists, retry: { reload += 1 }) { list in
|
||||||
if list.isEmpty {
|
if list.isEmpty {
|
||||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
EmptyState(icon: "music.note.list", title: "No Playlists",
|
||||||
@@ -19,14 +30,25 @@ struct PlaylistsScreen: View {
|
|||||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let stems = g.stemPlaylists.lists
|
||||||
|
if stems.isEmpty {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
|
||||||
|
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
|
||||||
|
}
|
||||||
|
ForEach(stems) { pl in
|
||||||
|
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +65,130 @@ struct PlaylistsScreen: View {
|
|||||||
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
||||||
else { ui.overlays.actions("New Playlist", acts) }
|
else { ui.overlays.actions("New Playlist", acts) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func newStemPlaylist() {
|
||||||
|
let menu = ui.menu
|
||||||
|
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
|
||||||
|
ui.overlays.actions("New Stem Playlist", [
|
||||||
|
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
|
||||||
|
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
|
||||||
|
Task {
|
||||||
|
guard let plex = try? await g.plex.playlists() else {
|
||||||
|
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
|
||||||
|
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StemPlaylistRow: View {
|
||||||
|
let playlist: StemPlaylist
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
||||||
|
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Tag(text: "STEMS")
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||||
|
}
|
||||||
|
.frame(height: 76)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.listRow(inset: 94, trailing: 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stem playlist: lives on this device, and every song plays with its saved mix.
|
||||||
|
struct StemPlaylistScreen: View {
|
||||||
|
let id: String
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var editing = false
|
||||||
|
@State private var items: [StemPlaylistItem] = []
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let pl = g.stemPlaylists.list(id) {
|
||||||
|
page(pl)
|
||||||
|
} else {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func page(_ pl: StemPlaylist) -> some View {
|
||||||
|
Group {
|
||||||
|
if editing {
|
||||||
|
List {
|
||||||
|
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||||
|
ForEach(items) { it in
|
||||||
|
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
|
||||||
|
}
|
||||||
|
.onDelete { items.remove(atOffsets: $0) }
|
||||||
|
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(p.bg)
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
.navigationTitle(pl.title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
} else {
|
||||||
|
Page(title: pl.title, large: false) {
|
||||||
|
header(pl)
|
||||||
|
if pl.items.isEmpty {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
|
||||||
|
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
|
||||||
|
}
|
||||||
|
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
|
||||||
|
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
|
||||||
|
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
|
||||||
|
}
|
||||||
|
.listRow(inset: MARGIN + 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
|
||||||
|
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
|
||||||
|
if !editing { MoreMenu(items: moreItems(pl)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(_ pl: StemPlaylist) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
|
||||||
|
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||||
|
.padding(.horizontal, 32).padding(.top, 14)
|
||||||
|
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
|
||||||
|
if !editing && !pl.items.isEmpty {
|
||||||
|
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
|
||||||
|
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8).padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
let rename = SheetAction("Rename…", icon: "pencil") {
|
||||||
|
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
|
||||||
|
}
|
||||||
|
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
|
||||||
|
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
|
||||||
|
}
|
||||||
|
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlaylistRow: View {
|
struct PlaylistRow: View {
|
||||||
|
|||||||
@@ -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.2"
|
MARKETING_VERSION: "1.0.8"
|
||||||
CURRENT_PROJECT_VERSION: "3"
|
CURRENT_PROJECT_VERSION: "9"
|
||||||
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.2/; s/$(CURRENT_PROJECT_VERSION)/3/' \
|
-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,77 @@
|
|||||||
|
{
|
||||||
|
"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.7",
|
||||||
|
"buildVersion": "8",
|
||||||
|
"date": "2026-10-20T09:39:03-07:00",
|
||||||
|
"localizedDescription": "- Mini player now sits in the tab bar's Liquid Glass accessory on iOS 26.1 and later, like Music. The tab bar shrinks when you scroll down and the player collapses to title and play/pause. Older iOS keeps the floating card.\n- Settings > Sound > Karaoke Soft Vocals: set how quiet Soft vocals are (-30 to -3 dB, default -12).\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.7/stemify-1.0.7.ipa",
|
||||||
|
"size": 1390623,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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