Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import AppIntents
|
||||
|
||||
/// What a widget or control button asks the player to do. [MixRemote] carries it out: the app's version drives
|
||||
/// the player, the widget extension's does nothing, since audio intents run in the app's process.
|
||||
enum MixAction: Sendable {
|
||||
case cycle
|
||||
case nudge(String, Int)
|
||||
case mute(String)
|
||||
case playPause
|
||||
}
|
||||
|
||||
struct CycleMixIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Change Mix"
|
||||
static let description = IntentDescription("Moves to the next stem mix, in the order set in the Stem Editor.")
|
||||
|
||||
init() {}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
await MixRemote.run(.cycle)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct NudgeStemIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Turn a Stem Up or Down"
|
||||
static let isDiscoverable = false
|
||||
|
||||
@Parameter(title: "Stem") var stem: String
|
||||
@Parameter(title: "Steps") var steps: Int
|
||||
|
||||
init() {}
|
||||
init(stem: String, steps: Int) {
|
||||
self.stem = stem
|
||||
self.steps = steps
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
await MixRemote.run(.nudge(stem, steps))
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct MuteStemIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Mute or Unmute a Stem"
|
||||
static let isDiscoverable = false
|
||||
|
||||
@Parameter(title: "Stem") var stem: String
|
||||
|
||||
init() {}
|
||||
init(stem: String) { self.stem = stem }
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
await MixRemote.run(.mute(stem))
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayPauseIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Play or Pause"
|
||||
static let isDiscoverable = false
|
||||
|
||||
init() {}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
await MixRemote.run(.playPause)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// One stem as the widgets draw it. [level] is the fader position (0.8 is unity), [percent] the gain the mixer shows.
|
||||
struct WidgetStem: Codable, Hashable, Identifiable {
|
||||
var name: String
|
||||
var label: String
|
||||
var color: UInt32
|
||||
var level: Double
|
||||
var percent: Int
|
||||
var muted = false
|
||||
var solo = false
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// What's playing and how it's mixed, written by the app for the widgets and the lock screen control.
|
||||
struct WidgetSnapshot: Codable, Equatable {
|
||||
var title = ""
|
||||
var artist = ""
|
||||
/// The current mix ("Karaoke", "Custom Mix"…), empty unless stems are playing.
|
||||
var mix = ""
|
||||
/// What the Mix button moves to.
|
||||
var next = ""
|
||||
var playing = false
|
||||
/// Empty unless stems are playing.
|
||||
var stems: [WidgetStem] = []
|
||||
/// The art key the saved artwork belongs to, nil while it isn't saved yet.
|
||||
var art: String?
|
||||
|
||||
var hasTrack: Bool { !title.isEmpty }
|
||||
var hasStems: Bool { !stems.isEmpty }
|
||||
/// Muted, or left out by another stem's solo.
|
||||
func silent(_ s: WidgetStem) -> Bool { s.muted || stems.contains(where: \.solo) && !s.solo }
|
||||
}
|
||||
|
||||
enum WidgetStore {
|
||||
static let group = "group.org.cdnutter.stemify"
|
||||
static let mixerKind = "org.cdnutter.stemify.mixer"
|
||||
static let mixKind = "org.cdnutter.stemify.mix"
|
||||
static let controlKind = "org.cdnutter.stemify.mix-control"
|
||||
private static let key = "widgetSnapshot"
|
||||
|
||||
/// Without the App Group (some sideloading tools drop it) this is a private store and the widgets show Nothing Playing.
|
||||
private static var defaults: UserDefaults? { UserDefaults(suiteName: group) }
|
||||
|
||||
static var artURL: URL? {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)?.appendingPathComponent("art.jpg")
|
||||
}
|
||||
|
||||
static func load() -> WidgetSnapshot {
|
||||
guard let d = defaults?.data(forKey: key), let s = try? JSONDecoder().decode(WidgetSnapshot.self, from: d) else { return WidgetSnapshot() }
|
||||
return s
|
||||
}
|
||||
|
||||
static func save(_ s: WidgetSnapshot) {
|
||||
guard let d = try? JSONEncoder().encode(s) else { return }
|
||||
defaults?.set(d, forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ final class AppGraph {
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
let radio: WebRadio
|
||||
let car: CarLibrary
|
||||
|
||||
private init() {
|
||||
@@ -64,7 +65,16 @@ final class AppGraph {
|
||||
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)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex)
|
||||
radio = WebRadio(pm: playback)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex, radio: radio, settings: settings)
|
||||
radio.nowPlaying = nowPlaying
|
||||
car = CarLibrary(plex: carPlex, reach: reach, downloads: downloads, stems: stems, pm: playback)
|
||||
// Mix names and the rotation show on the lock screen and in the widgets, so redraw those when the Stem Editor or Appearance change them.
|
||||
let np = nowPlaying, pm = playback
|
||||
settings.onChange("acapella", "presets", "presetOrder", "hiddenPresets") {
|
||||
np.refresh()
|
||||
WidgetBridge.shared.publish(pm, settings)
|
||||
}
|
||||
WidgetBridge.shared.start(self)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ struct StemifyApp: App {
|
||||
RootView()
|
||||
}
|
||||
.onChange(of: phase) { _, p in
|
||||
if p == .active { AppGraph.shared.eq.refreshRoute() }
|
||||
guard p == .active else { return }
|
||||
AppGraph.shared.eq.refreshRoute()
|
||||
// Keep downloaded playlists in step with the server, like Plexamp.
|
||||
Task { await AppGraph.shared.downloads.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
/// Carries out widget and control taps. Audio intents run here in the app's process, starting the app in the background if it isn't running.
|
||||
@MainActor
|
||||
enum MixRemote {
|
||||
/// One knob step on the widget, in fader position. Ten steps from silent to full, with unity on a step.
|
||||
static let step: Float = 0.1
|
||||
|
||||
static func run(_ a: MixAction) {
|
||||
let g = AppGraph.shared, pm = g.playback
|
||||
switch a {
|
||||
case .cycle:
|
||||
if pm.source == .Stems { g.nowPlaying.cycleMix() }
|
||||
case let .nudge(name, steps):
|
||||
guard pm.source == .Stems, pm.stemNames.contains(name) else { break }
|
||||
let cur = pm.mix[name] ?? StemSetting()
|
||||
// Turning a muted knob brings it back first.
|
||||
if cur.muted { pm.toggleMute(name) }
|
||||
pm.setStemLevel(name, ((cur.level / step).rounded() + Float(steps)) * step)
|
||||
case let .mute(name):
|
||||
if pm.source == .Stems && pm.stemNames.contains(name) { pm.toggleMute(name) }
|
||||
case .playPause:
|
||||
if g.radio.active { g.radio.toggle() } else { pm.togglePlay() }
|
||||
}
|
||||
WidgetBridge.shared.publish(pm, g.settings, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the widgets' copy of what's playing up to date: the snapshot in the App Group, plus a small copy of the art.
|
||||
final class WidgetBridge: @unchecked Sendable {
|
||||
static let shared = WidgetBridge()
|
||||
|
||||
private var last: WidgetSnapshot?
|
||||
private var artKey: String?
|
||||
private var savedArt: String?
|
||||
|
||||
func start(_ g: AppGraph) {
|
||||
let pm = g.playback, s = g.settings, plex = g.plex
|
||||
pm.addListener { [weak self] in
|
||||
self?.loadArt(pm, plex)
|
||||
self?.publish(pm, s)
|
||||
}
|
||||
loadArt(pm, plex)
|
||||
publish(pm, s)
|
||||
}
|
||||
|
||||
/// Writes the snapshot and asks WidgetKit to redraw, but only when something the widgets show has changed.
|
||||
func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) {
|
||||
let snap = make(pm, s)
|
||||
guard force || snap != last else { return }
|
||||
last = snap
|
||||
WidgetStore.save(snap)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() }
|
||||
}
|
||||
|
||||
private func make(_ pm: PlaybackManager, _ s: Settings) -> WidgetSnapshot {
|
||||
guard let t = pm.current?.track else { return WidgetSnapshot() }
|
||||
var w = WidgetSnapshot(title: t.title, artist: t.artist, playing: pm.isPlaying, art: savedArt == t.thumb ? savedArt : nil)
|
||||
guard pm.source == .Stems else { return w }
|
||||
let names = pm.stemNames, mix = pm.mix
|
||||
w.mix = MixPresets.current(s, names, mix)?.label ?? "Custom Mix"
|
||||
w.next = MixPresets.next(s, names, mix).label
|
||||
w.stems = Stems.sort(names).map { n in
|
||||
let m = mix[n] ?? StemSetting()
|
||||
return WidgetStem(name: n, label: Stems.label(n), color: Stems.color(n), level: Double(m.level),
|
||||
percent: Int((stemTaper(m.level) * 100).rounded()), muted: m.muted, solo: m.solo)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
/// Saves a 300 px copy of the current art for the widgets, once per album.
|
||||
private func loadArt(_ pm: PlaybackManager, _ plex: PlexClient) {
|
||||
let key = pm.current?.track.thumb
|
||||
guard key != artKey else { return }
|
||||
artKey = key
|
||||
guard let key, let url = plex.artUrl(key, px: 300), let file = WidgetStore.artURL else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return }
|
||||
let side: CGFloat = 300
|
||||
let small = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)).image { _ in
|
||||
img.draw(in: CGRect(x: 0, y: 0, width: side, height: side))
|
||||
}
|
||||
guard let data = small.jpegData(compressionQuality: 0.85), (try? data.write(to: file, options: .atomic)) != nil else { return }
|
||||
self.savedArt = key
|
||||
self.publish(pm, AppGraph.shared.settings, force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ struct DownloadRecord: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// What gets downloaded, Plexamp-style: a whole playlist, album or artist, kept in step with the server.
|
||||
/// Its songs are ordinary [DownloadRecord]s, shared when two groups hold the same song.
|
||||
struct DownloadGroup: Codable, Identifiable, Equatable {
|
||||
enum Kind: String, Codable { case playlist, stemPlaylist, album, artist }
|
||||
var kind: Kind
|
||||
var key: String
|
||||
var title: String
|
||||
var subtitle = ""
|
||||
var thumb: String?
|
||||
var trackKeys: [String] = []
|
||||
var added = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
var id: String { Self.id(kind, key) }
|
||||
static func id(_ kind: Kind, _ key: String) -> String { "\(kind.rawValue):\(key)" }
|
||||
}
|
||||
|
||||
/// Offline copies: the track's stems (or the original file) fully fetched into
|
||||
/// [MediaCache] and pinned, plus the metadata needed to browse them with no server.
|
||||
@Observable
|
||||
@@ -35,6 +50,7 @@ final class Downloads: @unchecked Sendable {
|
||||
@ObservationIgnored private let cache: MediaCache
|
||||
@ObservationIgnored private let net: Connectivity
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let groupFile: URL
|
||||
@ObservationIgnored private let lock = NSCondition()
|
||||
@ObservationIgnored private var state: [String: DownloadRecord] = [:]
|
||||
@ObservationIgnored private var prog: [String: Float] = [:]
|
||||
@@ -47,6 +63,8 @@ final class Downloads: @unchecked Sendable {
|
||||
static let waveBuckets = 256
|
||||
|
||||
private(set) var records: [String: DownloadRecord] = [:]
|
||||
/// Group id -> group. Only touched on the main thread.
|
||||
private(set) var groups: [String: DownloadGroup] = [:]
|
||||
/// ratingKey -> 0..1 for queued and running downloads.
|
||||
private(set) var progress: [String: Float] = [:]
|
||||
|
||||
@@ -57,10 +75,24 @@ final class Downloads: @unchecked Sendable {
|
||||
self.cache = cache
|
||||
self.net = net
|
||||
file = dir.appendingPathComponent("downloads.json")
|
||||
groupFile = dir.appendingPathComponent("download-groups.json")
|
||||
if let data = try? Data(contentsOf: file), let list = try? JSONDecoder().decode([Lenient<DownloadRecord>].self, from: data) {
|
||||
for r in list.compactMap(\.value) { state[r.track.ratingKey] = r }
|
||||
}
|
||||
records = state
|
||||
if let data = try? Data(contentsOf: groupFile), let list = try? JSONDecoder().decode([DownloadGroup].self, from: data) {
|
||||
groups = Dictionary(list.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
} else {
|
||||
// Downloads from before groups: gather them back into their albums.
|
||||
for (k, rs) in Dictionary(grouping: state.values.filter { $0.track.albumKey != nil }, by: { $0.track.albumKey! }) {
|
||||
let t = rs[0].track
|
||||
let gr = DownloadGroup(kind: .album, key: k, title: t.album, subtitle: t.artist, thumb: t.thumb,
|
||||
trackKeys: rs.sorted { (($0.track.disc ?? 0), ($0.track.index ?? 0)) < (($1.track.disc ?? 0), ($1.track.index ?? 0)) }.map(\.track.ratingKey),
|
||||
added: rs.map(\.added).min() ?? 0)
|
||||
groups[gr.id] = gr
|
||||
}
|
||||
saveGroups()
|
||||
}
|
||||
for _ in 0..<2 {
|
||||
let t = Thread { [weak self] in self?.work() }
|
||||
t.qualityOfService = .utility
|
||||
@@ -72,6 +104,61 @@ final class Downloads: @unchecked Sendable {
|
||||
|
||||
func isDownloaded(_ ratingKey: String) -> Bool { records[ratingKey]?.done == true }
|
||||
|
||||
func group(_ kind: DownloadGroup.Kind, _ key: String) -> DownloadGroup? { groups[DownloadGroup.id(kind, key)] }
|
||||
|
||||
/// Songs no group holds (single downloads from older versions).
|
||||
var looseRecords: [DownloadRecord] {
|
||||
let held = Set(groups.values.flatMap(\.trackKeys))
|
||||
return records.values.filter { !held.contains($0.track.ratingKey) }
|
||||
}
|
||||
|
||||
/// Downloads [gr] with these songs, or brings an existing group up to date: new songs are fetched,
|
||||
/// songs that left it are removed unless another group still holds them.
|
||||
func download(_ gr: DownloadGroup, _ tracks: [Track]) {
|
||||
var gr = gr
|
||||
if let old = groups[gr.id] { gr.added = old.added }
|
||||
let before = Set(groups[gr.id]?.trackKeys ?? [])
|
||||
gr.trackKeys = tracks.map(\.ratingKey)
|
||||
groups[gr.id] = gr
|
||||
saveGroups()
|
||||
download(tracks)
|
||||
prune(before.subtracting(gr.trackKeys))
|
||||
}
|
||||
|
||||
func removeGroup(_ id: String) {
|
||||
guard let gr = groups.removeValue(forKey: id) else { return }
|
||||
saveGroups()
|
||||
prune(Set(gr.trackKeys))
|
||||
}
|
||||
|
||||
/// Removes the songs among [keys] that no group holds any more.
|
||||
private func prune(_ keys: Set<String>) {
|
||||
let held = Set(groups.values.flatMap(\.trackKeys))
|
||||
let gone = keys.subtracting(held)
|
||||
if !gone.isEmpty { remove(gone.compactMap { records[$0]?.track }) }
|
||||
}
|
||||
|
||||
/// Brings every group in line with the server: playlists gain and lose songs, artists gain albums.
|
||||
@MainActor func refresh() async {
|
||||
for gr in groups.values {
|
||||
let tracks: [Track]?
|
||||
switch gr.kind {
|
||||
case .playlist: tracks = try? await plex.playlistTracks(gr.key)
|
||||
case .album: tracks = try? await plex.albumTracks(gr.key)
|
||||
case .artist: tracks = try? await plex.artistTracks(gr.key)
|
||||
case .stemPlaylist: tracks = AppGraph.shared.stemPlaylists.lists.first { $0.id == gr.key }?.tracks
|
||||
}
|
||||
if let tracks, !tracks.isEmpty, let cur = groups[gr.id], cur.trackKeys != tracks.map(\.ratingKey) { download(cur, tracks) }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGroups() {
|
||||
let list = groups.values.sorted { $0.added < $1.added }
|
||||
writeQueue.async { [groupFile] in
|
||||
if let data = try? JSONEncoder().encode(list) { try? data.write(to: groupFile, options: .atomic) }
|
||||
}
|
||||
}
|
||||
|
||||
func download(_ tracks: [Track]) {
|
||||
lock.lock()
|
||||
let add = tracks.filter { t in
|
||||
|
||||
@@ -76,7 +76,7 @@ final class CarLibrary: @unchecked Sendable {
|
||||
default:
|
||||
guard let (kind, key) = CarIds.split(id) else { return nil }
|
||||
switch kind {
|
||||
case "playlist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
|
||||
case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
|
||||
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
|
||||
case "artist": return try await artistPage(key, away)
|
||||
case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) }
|
||||
@@ -95,7 +95,13 @@ final class CarLibrary: @unchecked Sendable {
|
||||
} else {
|
||||
shown = all
|
||||
}
|
||||
return shown.map { playlistItem($0) }
|
||||
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { !$0.items.isEmpty } }
|
||||
let stemItems = stem.map { l in
|
||||
CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"),
|
||||
art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists")
|
||||
}
|
||||
let plexItems = shown.map { playlistItem($0, group: stemItems.isEmpty ? nil : "Plex Playlists") }
|
||||
return stemItems + plexItems
|
||||
}
|
||||
|
||||
private func albumsTab(_ away: Bool) async throws -> [CarItem] {
|
||||
@@ -168,6 +174,7 @@ final class CarLibrary: @unchecked Sendable {
|
||||
guard let (kind, key) = CarIds.split(container) else { return [] }
|
||||
switch kind {
|
||||
case "playlist": all = try await plex.playlistTracks(key)
|
||||
case "stemplaylist": all = await MainActor.run { AppGraph.shared.stemPlaylists.list(key)?.tracks ?? [] }
|
||||
case "album": all = try await plex.albumTracks(key)
|
||||
case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key)
|
||||
case "search": all = try await searchTracks(key, away)
|
||||
|
||||
@@ -172,7 +172,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow
|
||||
guard let self else { return }
|
||||
let label = self.g.nowPlaying.nextMix.label
|
||||
self.g.nowPlaying.cycleMix()
|
||||
self.alert("Mix: \(label)")
|
||||
self.alert(label)
|
||||
})
|
||||
}
|
||||
let shuffle = CPNowPlayingShuffleButton { [weak self] _ in self?.g.playback.toggleShuffle() }
|
||||
|
||||
@@ -49,12 +49,13 @@ final class HomeReach: @unchecked Sendable {
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let (at, last) = stamp.with { (checkedAt, _last) }
|
||||
if at != 0 && now - at < Self.ttl { return last }
|
||||
let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity"
|
||||
let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"
|
||||
async let a = answers(plex)
|
||||
async let b = answers(stems)
|
||||
let (ra, rb) = await (a, b)
|
||||
let ok = ra || rb
|
||||
let urls = settings.servers.map { $0.base + ($0.kind == .plex ? "/identity" : "/System/Info/Public") }
|
||||
+ [settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"]
|
||||
let ok = await withTaskGroup(of: Bool.self) { g in
|
||||
for u in urls { g.addTask { await self.answers(u) } }
|
||||
for await r in g where r { g.cancelAll(); return true }
|
||||
return false
|
||||
}
|
||||
stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds }
|
||||
await MainActor.run {
|
||||
if home != ok {
|
||||
|
||||
@@ -97,13 +97,19 @@ final class Settings: @unchecked Sendable {
|
||||
clientId = UUID().uuidString.lowercased()
|
||||
defaults.set(clientId, forKey: "clientId")
|
||||
}
|
||||
// Single-Plex settings from before multi-server become the first server, with bare keys.
|
||||
if store["servers"] == nil, let t = store["plexToken"] as? String, !t.isEmpty {
|
||||
let sections = (store["musicSection"] as? String ?? "").split(separator: ",").map(String.init)
|
||||
servers = [MediaServer(id: "", kind: .plex, name: "Plex", url: plexUrl, token: t, user: plexUser,
|
||||
machineId: plexMachineId, sections: sections, sectionTitles: musicSectionTitle)]
|
||||
}
|
||||
}
|
||||
|
||||
private static let keys = [
|
||||
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
||||
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
||||
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly",
|
||||
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella",
|
||||
]
|
||||
|
||||
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
||||
@@ -171,6 +177,34 @@ final class Settings: @unchecked Sendable {
|
||||
var plexMachineId: String { get { read(\.plexMachineId, "plexMachineId", "") } set { write(\.plexMachineId, "plexMachineId", newValue, "") } }
|
||||
var musicSection: String { get { read(\.musicSection, "musicSection", "") } set { write(\.musicSection, "musicSection", newValue, "") } }
|
||||
var musicSectionTitle: String { get { read(\.musicSectionTitle, "musicSectionTitle", "") } set { write(\.musicSectionTitle, "musicSectionTitle", newValue, "") } }
|
||||
/// Music servers as JSON; use [servers].
|
||||
/// Web radio stations as JSON ([WebStation]).
|
||||
var stationsJson: String { get { read(\.stationsJson, "webStations", "") } set { write(\.stationsJson, "webStations", newValue, "") } }
|
||||
var stations: [WebStation] {
|
||||
get { (try? JSONDecoder().decode([WebStation].self, from: Data(stationsJson.utf8))) ?? [] }
|
||||
set { stationsJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" }
|
||||
}
|
||||
var serversJson: String { get { read(\.serversJson, "servers", "") } set { write(\.serversJson, "servers", newValue, "") } }
|
||||
var servers: [MediaServer] {
|
||||
get { (try? JSONDecoder().decode([MediaServer].self, from: Data(serversJson.utf8))) ?? [] }
|
||||
set { serversJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" }
|
||||
}
|
||||
/// Changes whenever the libraries being read change; screens reload on it.
|
||||
var libraryKey: String { servers.filter(\.ready).map { "\($0.id)@\($0.url)/\($0.sections.joined(separator: ","))" }.joined(separator: ";") }
|
||||
var libraryReady: Bool { servers.contains(where: \.ready) }
|
||||
|
||||
/// Adds or replaces a server by id.
|
||||
func save(_ server: MediaServer) {
|
||||
var list = servers
|
||||
if let i = list.firstIndex(where: { $0.id == server.id }) { list[i] = server } else { list.append(server) }
|
||||
servers = list
|
||||
}
|
||||
|
||||
func removeServer(_ id: String) { servers = servers.filter { $0.id != id } }
|
||||
|
||||
/// The id a new server gets: empty (bare keys) when no server holds it yet.
|
||||
func nextServerId() -> String { servers.contains { $0.id.isEmpty } ? MediaServer.newId() : "" }
|
||||
|
||||
var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } }
|
||||
var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } }
|
||||
/// Plex path prefixes that matched the stems index, newline-separated.
|
||||
@@ -179,6 +213,9 @@ final class Settings: @unchecked Sendable {
|
||||
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
|
||||
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
|
||||
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
||||
/// Spells the A Cappella mix "Acapella". MixPresets reads the key directly.
|
||||
var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } }
|
||||
var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } }
|
||||
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
||||
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
|
||||
|
||||
@@ -191,7 +228,19 @@ final class Settings: @unchecked Sendable {
|
||||
/// How far down Soft karaoke puts the vocals, in dB.
|
||||
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||
/// Library lists show only what has stems.
|
||||
var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false) } }
|
||||
var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false); if newValue { noStems = false } } }
|
||||
var noStems: Bool { get { read(\.noStems, "noStems", false) } set { write(\.noStems, "noStems", newValue, false); if newValue { stemsOnly = false } } }
|
||||
/// How many songs after the current one to cache ahead (0 to 50).
|
||||
var precacheCount: Int { get { read(\.precacheCount, "precacheCount", 5) } set { write(\.precacheCount, "precacheCount", min(50, max(0, newValue)), 5) } }
|
||||
/// Now-playing buttons the user hid, comma-separated.
|
||||
var hiddenNowPlaying: String { get { read(\.hiddenNowPlaying, "hiddenNowPlaying", "") } set { write(\.hiddenNowPlaying, "hiddenNowPlaying", newValue, "") } }
|
||||
/// App-wide saved stem mixes as JSON ([name: mix]).
|
||||
var presets: String { get { read(\.presets, "presets", "") } set { write(\.presets, "presets", newValue, "") } }
|
||||
/// Mixer bar order and hidden built-ins, newline-separated preset ids ("b:Karaoke", "c:My Mix").
|
||||
var presetOrder: String { get { read(\.presetOrder, "presetOrder", "") } set { write(\.presetOrder, "presetOrder", newValue, "") } }
|
||||
var hiddenPresets: String { get { read(\.hiddenPresets, "hiddenPresets", "") } set { write(\.hiddenPresets, "hiddenPresets", newValue, "") } }
|
||||
/// The id of the preset every song starts with, or empty.
|
||||
var lockedPreset: String { get { read(\.lockedPreset, "lockedPreset", "") } set { write(\.lockedPreset, "lockedPreset", newValue, "") } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var playbackSpeed: Double {
|
||||
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }
|
||||
|
||||
@@ -2,28 +2,74 @@ import Foundation
|
||||
|
||||
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
|
||||
struct MixPreset: Identifiable, Equatable {
|
||||
let label: String
|
||||
/// The stored name, which ids are built from. Show [label] instead.
|
||||
let name: String
|
||||
/// One the user saved, as opposed to a built-in.
|
||||
var custom = false
|
||||
let build: ([String]) -> [String: StemSetting]?
|
||||
var id: String { label }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
|
||||
var id: String { (custom ? "c:" : "b:") + name }
|
||||
/// The name as shown, with A Cappella spelled the way Appearance says.
|
||||
var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id }
|
||||
}
|
||||
|
||||
enum MixPresets {
|
||||
static let full = MixPreset(label: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
static let full = MixPreset(name: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(name: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
|
||||
static let all: [MixPreset] = [
|
||||
full,
|
||||
karaoke,
|
||||
MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "Rhythm") { n in
|
||||
MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "Rhythm") { n in
|
||||
n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil
|
||||
},
|
||||
MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
]
|
||||
|
||||
static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } }
|
||||
|
||||
/// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end).
|
||||
static func ordered(_ s: Settings) -> [MixPreset] {
|
||||
let pool = CustomPreset.load(s).map(custom) + all
|
||||
let order = s.presetOrder.split(separator: "\n").map(String.init)
|
||||
let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
return pool.enumerated().sorted { a, b in
|
||||
(rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset)
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
/// Set by Appearance. Read straight from UserDefaults so labels work off the main thread.
|
||||
static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") }
|
||||
|
||||
/// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count.
|
||||
static func hidden(_ s: Settings) -> Set<String> {
|
||||
Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") })
|
||||
}
|
||||
|
||||
/// The chips in the mixer bar, and the rotation for every Mix button outside the app.
|
||||
static func bar(_ s: Settings) -> [MixPreset] {
|
||||
let h = hidden(s)
|
||||
return ordered(s).filter { !h.contains($0.id) }
|
||||
}
|
||||
|
||||
static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" }
|
||||
|
||||
static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") }
|
||||
|
||||
static func setHidden(_ s: Settings, _ id: String, _ on: Bool) {
|
||||
var h = hidden(s)
|
||||
if on { h.insert(id) } else { h.remove(id) }
|
||||
s.hiddenPresets = h.sorted().joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked.
|
||||
static func locked(_ s: Settings) -> MixPreset? {
|
||||
s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset }
|
||||
}
|
||||
|
||||
/// Drops stems left at their defaults, so mixes compare by what they actually change.
|
||||
static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } }
|
||||
|
||||
@@ -33,10 +79,19 @@ enum MixPresets {
|
||||
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
|
||||
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = all.filter { $0.build(names) != nil }
|
||||
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
|
||||
/// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows.
|
||||
static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
|
||||
let m = normalized(mix)
|
||||
return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do.
|
||||
/// A hand-made mix, or one deleted from the editor, goes to the start of the list.
|
||||
static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = bar(s).filter { $0.build(names) != nil }
|
||||
guard !usable.isEmpty else { return full }
|
||||
let m = normalized(mix)
|
||||
guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] }
|
||||
return usable[(i + 1) % usable.count]
|
||||
}
|
||||
|
||||
@@ -48,3 +103,41 @@ enum MixPresets {
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets].
|
||||
struct CustomPreset: Codable, Identifiable, Equatable {
|
||||
var name: String
|
||||
var mix: [String: StemSetting]
|
||||
var id: String { name }
|
||||
|
||||
@MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) }
|
||||
|
||||
static func load(_ s: Settings) -> [CustomPreset] {
|
||||
guard let d = s.presets.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? []
|
||||
}
|
||||
|
||||
@MainActor static func save(_ list: [CustomPreset]) {
|
||||
guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return }
|
||||
AppGraph.shared.settings.presets = s
|
||||
}
|
||||
|
||||
/// Adds or replaces the preset with this name.
|
||||
@MainActor static func add(_ name: String, _ mix: [String: StemSetting]) {
|
||||
var l = load().filter { $0.name != name }
|
||||
l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix)))
|
||||
save(l)
|
||||
}
|
||||
|
||||
@MainActor static func remove(_ name: String) {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset == "c:" + name { s.lockedPreset = "" }
|
||||
save(load().filter { $0.name != name })
|
||||
}
|
||||
|
||||
@MainActor static func removeAll() {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" }
|
||||
save([])
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import UIKit
|
||||
final class NowPlaying: @unchecked Sendable {
|
||||
private let pm: PlaybackManager
|
||||
private let plex: PlexClient
|
||||
private let radio: WebRadio
|
||||
private let settings: Settings
|
||||
private let center = MPNowPlayingInfoCenter.default()
|
||||
private var artKey: String?
|
||||
private var artwork: MPMediaItemArtwork?
|
||||
|
||||
init(pm: PlaybackManager, plex: PlexClient) {
|
||||
init(pm: PlaybackManager, plex: PlexClient, radio: WebRadio, settings: Settings) {
|
||||
self.pm = pm
|
||||
self.plex = plex
|
||||
self.radio = radio
|
||||
self.settings = settings
|
||||
pm.addListener { [weak self] in self?.publish() }
|
||||
wireCommands()
|
||||
publish()
|
||||
@@ -25,13 +29,14 @@ final class NowPlaying: @unchecked Sendable {
|
||||
/// The mix shown next to the artist: nil unless stems are playing or the mix is Full.
|
||||
var mixLabel: String? {
|
||||
guard pm.source == .Stems else { return nil }
|
||||
guard let p = MixPresets.current(pm.stemNames, pm.mix) else { return "Custom mix" }
|
||||
guard let p = MixPresets.current(settings, pm.stemNames, pm.mix) else { return "Custom mix" }
|
||||
return p == MixPresets.full ? nil : p.label
|
||||
}
|
||||
|
||||
var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") }
|
||||
var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) }
|
||||
var nextMix: MixPreset { MixPresets.next(pm.stemNames, pm.mix) }
|
||||
/// Follows the Stem Editor: deleted mixes are skipped and the user's own are in the rotation.
|
||||
var nextMix: MixPreset { MixPresets.next(settings, pm.stemNames, pm.mix) }
|
||||
|
||||
/// One tap: hear the vocals again, or take them out.
|
||||
func toggleVocals() {
|
||||
@@ -50,7 +55,11 @@ final class NowPlaying: @unchecked Sendable {
|
||||
|
||||
// ---- info --------------------------------------------------------------------------
|
||||
|
||||
/// Puts the queue back on the lock screen (after the web radio stops).
|
||||
func refresh() { publish() }
|
||||
|
||||
private func publish() {
|
||||
if radio.active { return }
|
||||
guard let t = pm.current?.track else {
|
||||
center.nowPlayingInfo = nil
|
||||
center.playbackState = .stopped
|
||||
@@ -92,16 +101,21 @@ final class NowPlaying: @unchecked Sendable {
|
||||
|
||||
private func wireCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
func on(_ cmd: MPRemoteCommand, _ f: @escaping @MainActor (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
cmd.isEnabled = true
|
||||
cmd.addTarget { e in MainActor.assumeIsolated { f(e) } }
|
||||
}
|
||||
on(c.playCommand) { [pm] _ in pm.play(); return .success }
|
||||
on(c.pauseCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.stopCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.togglePlayPauseCommand) { [pm] _ in pm.togglePlay(); return .success }
|
||||
on(c.nextTrackCommand) { [pm] _ in pm.next(); return .success }
|
||||
on(c.previousTrackCommand) { [pm] _ in pm.previous(); return .success }
|
||||
// While a web radio station is loaded, play/pause drive it and skipping does nothing.
|
||||
on(c.playCommand) { [pm, radio] _ in if radio.active { radio.resume() } else { pm.play() }; return .success }
|
||||
on(c.pauseCommand) { [pm, radio] _ in if radio.active { radio.pause() } else { pm.pause() }; return .success }
|
||||
on(c.stopCommand) { [pm, radio] _ in if radio.active { radio.stop() } else { pm.pause() }; return .success }
|
||||
on(c.togglePlayPauseCommand) { [pm, radio] _ in if radio.active { radio.toggle() } else { pm.togglePlay() }; return .success }
|
||||
on(c.nextTrackCommand) { [pm, radio] _ in if !radio.active { pm.next() }; return .success }
|
||||
on(c.previousTrackCommand) { [weak self, pm, radio] _ in
|
||||
guard !radio.active else { return .success }
|
||||
if self?.settings.lockScreenMix == true && pm.source == .Stems { self?.cycleMix() } else { pm.previous() }
|
||||
return .success
|
||||
}
|
||||
on(c.changePlaybackPositionCommand) { [pm] e in
|
||||
guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
pm.seekTo(Int64(e.positionTime * 1000))
|
||||
|
||||
@@ -100,7 +100,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
"stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)"
|
||||
}
|
||||
static func plexKey(_ track: Track) -> String {
|
||||
"plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
"plex|\(track.ratingKey.firstIndex(of: "~").map { String(track.ratingKey[...$0]) } ?? "")\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
}
|
||||
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@@ -131,6 +131,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
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
|
||||
/// The last mix set by hand; the next song starts from it (never a song's saved mix).
|
||||
@ObservationIgnored private var carryMix: [String: StemSetting] = [:]
|
||||
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||
|
||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||
@@ -142,9 +144,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var nextTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var currentToken: Int64 = -1
|
||||
@ObservationIgnored private var nextToken: Int64 = -1
|
||||
@ObservationIgnored private var nextIndex = -1
|
||||
@ObservationIgnored private var nextEntry: StemEntry?
|
||||
/// Sessions handed to the engine for a gapless switch, by token: which queue item and entry they are.
|
||||
/// Kept until a switch or load, since the engine may already have switched when the queue changes.
|
||||
@ObservationIgnored private var queued: [Int64: (uid: Int64, entry: StemEntry?)] = [:]
|
||||
/// The queue ran out and playback stopped at its last song.
|
||||
@ObservationIgnored private var finished = false
|
||||
@ObservationIgnored private var currentEntry: StemEntry?
|
||||
@ObservationIgnored private var pendingPositionMs: Int64 = 0
|
||||
@ObservationIgnored private var failures = 0
|
||||
@@ -218,13 +222,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
|
||||
/// Shuffles whole albums, keeping each album's tracks in order (Plexamp-style), when the setting is on.
|
||||
private func shuffled(_ items: [QueueItem]) -> [QueueItem] {
|
||||
items.shuffled()
|
||||
}
|
||||
|
||||
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks, mixes)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
|
||||
queue = [first] + shuffled(items.filter { $0.uid != first.uid })
|
||||
shuffle = true
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
@@ -245,8 +254,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
unshuffled = unshuffled.map { $0 + items }
|
||||
if index < 0 {
|
||||
load(0, 0, true)
|
||||
if index < 0 || finished {
|
||||
load(finished ? index + 1 : 0, 0, true)
|
||||
} else {
|
||||
requeueNext()
|
||||
changed()
|
||||
@@ -303,7 +312,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
let cur = q.indices.contains(idx) ? q[idx] : nil
|
||||
if !shuffle {
|
||||
unshuffled = q
|
||||
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
|
||||
queue = Array(q.prefix(idx + 1)) + shuffled(Array(q.dropFirst(idx + 1)))
|
||||
shuffle = true
|
||||
} else {
|
||||
let orig = unshuffled ?? q
|
||||
@@ -376,6 +385,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
/// Skips past the rest of the current album (holding Next).
|
||||
func nextAlbum() {
|
||||
guard let album = current?.track.albumKey, index >= 0 else { return next() }
|
||||
if let i = queue.indices.dropFirst(index + 1).first(where: { queue[$0].track.albumKey != album }) { load(i, 0, playWhenReady) }
|
||||
else if repeatMode != .Off { load(0, 0, playWhenReady) }
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
func previous() {
|
||||
if positionMs() > 3000 || index <= 0 {
|
||||
seekTo(0)
|
||||
@@ -401,7 +418,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
engine.stop()
|
||||
reportTimeline("stopped")
|
||||
currentToken = -1
|
||||
nextToken = -1
|
||||
queued = [:]
|
||||
queue = []
|
||||
index = -1
|
||||
current = nil
|
||||
@@ -428,14 +445,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
/// Karaoke and friends: set every stem at once.
|
||||
func applyMix(_ m: [String: StemSetting]) {
|
||||
mix = m
|
||||
carryMix = m
|
||||
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 && !settings.keepStemMix { karaoke = false; preKaraokeMix = nil }
|
||||
if karaoke { return karaokeMix }
|
||||
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
if let l = MixPresets.locked(settings), let m = l.build(StemPlaylists.allStems) { return m }
|
||||
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? carryMix : [:])
|
||||
}
|
||||
|
||||
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||
@@ -491,6 +511,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
var s = mix[name] ?? StemSetting()
|
||||
f(&s)
|
||||
mix[name] = s
|
||||
carryMix = mix
|
||||
pushMix()
|
||||
}
|
||||
|
||||
@@ -533,7 +554,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
let item = queue[i]
|
||||
loadTask?.cancel()
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
queued = [:]
|
||||
finished = false
|
||||
engine.queueNext(nil)
|
||||
index = i
|
||||
current = item
|
||||
@@ -649,7 +671,6 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
/// Builds the next track's session now so the switch can be gapless.
|
||||
private func prepareNext() {
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
guard let i = upcomingIndex() else { return }
|
||||
let item = queue[i]
|
||||
@@ -666,10 +687,27 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.nextToken = s.token
|
||||
self.nextIndex = i
|
||||
self.nextEntry = entry
|
||||
self.queued[s.token] = (item.uid, entry)
|
||||
self.engine.queueNext(s)
|
||||
await self.precacheAhead(from: i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Warms the cache for the songs after the gapless one, up to the Cache Ahead setting.
|
||||
private func precacheAhead(from i: Int) async {
|
||||
let n = settings.precacheCount - 1
|
||||
guard n > 0 else { return }
|
||||
for k in 1...n {
|
||||
let j = i + k
|
||||
guard !Task.isCancelled, j < queue.count else { return }
|
||||
let t = queue[j].track
|
||||
if playableOffline(t) { continue }
|
||||
guard let (s, _) = try? await openDetached(t, 0) else { continue }
|
||||
// Keep the session open until its prefetch has filled the cache.
|
||||
for _ in 0..<300 where !Task.isCancelled && !playableOffline(t) {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
s.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,13 +747,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func onTransition(_ token: Int64) {
|
||||
if token == currentToken || token != nextToken { return }
|
||||
guard token != currentToken, let q = queued[token] else { return }
|
||||
queued = [:]
|
||||
scrobble()
|
||||
currentToken = token
|
||||
nextToken = -1
|
||||
index = nextIndex
|
||||
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
|
||||
currentEntry = nextEntry
|
||||
let i = queue.firstIndex { $0.uid == q.uid } ?? min(index + 1, queue.count - 1)
|
||||
index = i
|
||||
current = queue.indices.contains(i) ? queue[i] : nil
|
||||
currentEntry = q.entry
|
||||
pendingPositionMs = 0
|
||||
scrobbled = -1
|
||||
guard let item = current else { return }
|
||||
@@ -743,6 +782,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
playWhenReady = false
|
||||
pendingPositionMs = 0
|
||||
finished = true
|
||||
reportTimeline("stopped")
|
||||
changed()
|
||||
save()
|
||||
|
||||
@@ -30,7 +30,7 @@ final class StemPlaylists {
|
||||
@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"]
|
||||
nonisolated static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||
|
||||
init(dir: URL) {
|
||||
file = dir.appendingPathComponent("stem-playlists.json")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
/// An internet radio stream the user saved in Browse > Web Radio.
|
||||
struct WebStation: Codable, Hashable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
var name: String
|
||||
var url: String
|
||||
}
|
||||
|
||||
/// Plays a web radio stream through AVPlayer, outside the stem engine. Starting a station pauses
|
||||
/// the queue, and playing the queue again stops the station. Main-thread only, like PlaybackManager.
|
||||
@Observable
|
||||
final class WebRadio: @unchecked Sendable {
|
||||
private let pm: PlaybackManager
|
||||
@ObservationIgnored private let player = AVPlayer()
|
||||
@ObservationIgnored weak var nowPlaying: NowPlaying?
|
||||
|
||||
private(set) var station: WebStation?
|
||||
private(set) var playing = false
|
||||
/// A station is loaded (playing or paused); the lock screen belongs to the radio.
|
||||
var active: Bool { station != nil }
|
||||
|
||||
init(pm: PlaybackManager) {
|
||||
self.pm = pm
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
pm.addListener { [weak self] in
|
||||
guard let self, self.active, pm.playWhenReady else { return }
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func play(_ s: WebStation) {
|
||||
guard let url = URL(string: s.url) else { return }
|
||||
station = s
|
||||
if pm.playWhenReady { pm.pause() }
|
||||
let a = AVAudioSession.sharedInstance()
|
||||
try? a.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
try? a.setActive(true)
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||
player.play()
|
||||
playing = true
|
||||
publish()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard active else { return }
|
||||
// Live streams go stale while paused; rejoin at the live edge.
|
||||
if let s = station { play(s) }
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
playing = false
|
||||
publish()
|
||||
}
|
||||
|
||||
func toggle() { if playing { pause() } else { resume() } }
|
||||
|
||||
func stop() {
|
||||
player.pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
station = nil
|
||||
playing = false
|
||||
nowPlaying?.refresh()
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard let s = station else { return }
|
||||
let c = MPNowPlayingInfoCenter.default()
|
||||
c.nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: s.name,
|
||||
MPMediaItemPropertyArtist: "Web Radio",
|
||||
MPNowPlayingInfoPropertyIsLiveStream: true,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: playing ? 1.0 : 0.0,
|
||||
]
|
||||
c.playbackState = playing ? .playing : .paused
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// A Jellyfin item, read leniently like PlexItem.
|
||||
struct JItem: Decodable {
|
||||
var id = ""
|
||||
var name = ""
|
||||
var type = ""
|
||||
var sortName: String?
|
||||
var album: String?
|
||||
var albumId: String?
|
||||
var albumArtist: String?
|
||||
var albumArtistId: String?
|
||||
var artistId: String?
|
||||
var artist: String?
|
||||
var index: Int?
|
||||
var disc: Int?
|
||||
var ticks: Int64?
|
||||
var year: Int?
|
||||
var premiere: String?
|
||||
var path: String?
|
||||
var size: Int64?
|
||||
var container: String?
|
||||
var primaryTag: String?
|
||||
var albumPrimaryTag: String?
|
||||
var childCount: Int?
|
||||
var genre: String?
|
||||
var mediaType: String?
|
||||
var playlistItemId: String?
|
||||
var collectionType: String?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("Id") ?? ""
|
||||
name = c.str("Name") ?? ""
|
||||
type = c.str("Type") ?? ""
|
||||
sortName = c.str("SortName")
|
||||
album = c.str("Album")
|
||||
albumId = c.str("AlbumId")
|
||||
albumArtist = c.str("AlbumArtist")
|
||||
let albumArtists: [JRef] = c.list("AlbumArtists")
|
||||
let artists: [JRef] = c.list("ArtistItems")
|
||||
albumArtistId = albumArtists.first?.id
|
||||
artistId = artists.first?.id ?? albumArtistId
|
||||
artist = artists.first?.name
|
||||
index = c.int("IndexNumber")
|
||||
disc = c.int("ParentIndexNumber")
|
||||
ticks = c.int64("RunTimeTicks")
|
||||
year = c.int("ProductionYear")
|
||||
premiere = c.str("PremiereDate")
|
||||
path = c.str("Path")
|
||||
let sources: [JSource] = c.list("MediaSources")
|
||||
size = sources.first?.size
|
||||
container = sources.first?.container ?? c.str("Container")
|
||||
if path == nil { path = sources.first?.path }
|
||||
primaryTag = (try? c.decodeIfPresent([String: String].self, forKey: AnyKey("ImageTags")))?["Primary"]
|
||||
albumPrimaryTag = c.str("AlbumPrimaryImageTag")
|
||||
childCount = c.int("ChildCount") ?? c.int("SongCount")
|
||||
genre = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Genres")))?.first
|
||||
mediaType = c.str("MediaType")
|
||||
playlistItemId = c.str("PlaylistItemId")
|
||||
collectionType = c.str("CollectionType")
|
||||
}
|
||||
|
||||
var durationMs: Int64 { (ticks ?? 0) / 10_000 }
|
||||
var thumb: String? { primaryTag != nil ? id : albumPrimaryTag != nil ? albumId : nil }
|
||||
|
||||
func toTrack(_ position: Int? = nil) -> Track {
|
||||
Track(ratingKey: id, title: name, artist: artist ?? albumArtist ?? "Unknown Artist", album: album ?? "",
|
||||
albumKey: albumId, artistKey: albumArtistId ?? artistId, index: index, disc: disc, durationMs: durationMs,
|
||||
thumb: thumb, partKey: id, partId: stableId(id), partFile: path,
|
||||
partSize: size, codec: container, year: year, playlistItemId: position.map(Int64.init))
|
||||
}
|
||||
|
||||
func toAlbum() -> Album {
|
||||
Album(ratingKey: id, title: name, artist: albumArtist ?? "Unknown Artist", artistKey: albumArtistId, year: year,
|
||||
thumb: thumb, trackCount: childCount, genre: genre, released: premiere.map { String($0.prefix(10)) })
|
||||
}
|
||||
|
||||
func toArtist() -> Artist { Artist(ratingKey: id, title: name, sortTitle: sortName ?? name, thumb: thumb) }
|
||||
|
||||
func toPlaylist() -> Playlist {
|
||||
Playlist(ratingKey: id, title: name, thumb: thumb, trackCount: childCount ?? 0, durationMs: durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a, so the cache key for a Jellyfin file is the same on every launch.
|
||||
private func stableId(_ s: String) -> Int64 {
|
||||
var h: UInt64 = 0xcbf29ce484222325
|
||||
for b in s.utf8 { h = (h ^ UInt64(b)) &* 0x100000001b3 }
|
||||
return Int64(bitPattern: h & 0x7FFF_FFFF_FFFF_FFFF)
|
||||
}
|
||||
|
||||
private struct JRef: Decodable {
|
||||
var id = ""
|
||||
var name = ""
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("Id") ?? ""
|
||||
name = c.str("Name") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct JSource: Decodable {
|
||||
var size: Int64?
|
||||
var container: String?
|
||||
var path: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
size = c.int64("Size")
|
||||
container = c.str("Container")
|
||||
path = c.str("Path")
|
||||
}
|
||||
}
|
||||
|
||||
private struct JList: Decodable {
|
||||
var items: [JItem] = []
|
||||
var total = 0
|
||||
init(from decoder: Decoder) throws {
|
||||
if let arr = try? [Lenient<JItem>](from: decoder) {
|
||||
items = arr.compactMap(\.value)
|
||||
total = items.count
|
||||
return
|
||||
}
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
items = c.list("Items")
|
||||
total = c.int("TotalRecordCount") ?? items.count
|
||||
}
|
||||
}
|
||||
|
||||
private struct JFolder: Decodable {
|
||||
var id = ""
|
||||
var locations: [String] = []
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("ItemId") ?? ""
|
||||
locations = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Locations"))) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct JellyfinLogin {
|
||||
var token: String
|
||||
var userId: String
|
||||
var user: String
|
||||
var serverId: String
|
||||
}
|
||||
|
||||
/// One Jellyfin server. Tracks keep the file Path so stem matching works the same as on Plex.
|
||||
final class JellyfinClient: MediaSource, @unchecked Sendable {
|
||||
private static let page = 1000
|
||||
private static let fields = "Path,MediaSources,SortName,Genres,PremiereDate,ChildCount,ProductionYear"
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
let server: MediaServer
|
||||
|
||||
init(http: Http, settings: Settings, server: MediaServer) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
self.server = server
|
||||
}
|
||||
|
||||
private var base: String { server.base }
|
||||
private var uid: String { server.userId }
|
||||
|
||||
private func request(_ url: URL, method: String = "GET", token: String? = nil) -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let t = token ?? server.token
|
||||
var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.1\""
|
||||
if !t.isEmpty { auth += ", Token=\"\(t)\"" }
|
||||
r.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad Jellyfin address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ params: [(String, Any?)] = []) async throws -> JList {
|
||||
guard server.signedIn else { throw NotSignedIn() }
|
||||
return try JSONDecoder().decode(JList.self, from: try await http.data(request(try url(path, params))))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = [], body: [String: Any]? = nil) async throws -> Data {
|
||||
guard server.signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params), method: method)
|
||||
if let body {
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
} else if method != "DELETE" { req.httpBody = Data() }
|
||||
return try await http.data(req)
|
||||
}
|
||||
|
||||
/// Plex sort keys ("addedAt:desc", "artist.titleSort,year") to Jellyfin's SortBy and SortOrder.
|
||||
private func sortParams(_ sort: String, tracks: Bool = false) -> [(String, Any?)] {
|
||||
let desc = sort.contains(":desc")
|
||||
let first = sort.split(separator: ",").first.map { String($0).replacingOccurrences(of: ":desc", with: "") } ?? ""
|
||||
let by: String = switch first {
|
||||
case "addedAt", "updatedAt": "DateCreated"
|
||||
case "lastViewedAt": "DatePlayed"
|
||||
case "viewCount": "PlayCount"
|
||||
case "userRating": "CommunityRating"
|
||||
case "year": "ProductionYear,SortName"
|
||||
case "originallyAvailableAt": "PremiereDate,SortName"
|
||||
case "duration": "Runtime"
|
||||
case "random": "Random"
|
||||
case "artist.titleSort": tracks ? "AlbumArtist,Album,ParentIndexNumber,IndexNumber" : "AlbumArtist,ProductionYear"
|
||||
case "album.titleSort": "Album,ParentIndexNumber,IndexNumber"
|
||||
default: "SortName"
|
||||
}
|
||||
return [("SortBy", by), ("SortOrder", desc ? "Descending" : "Ascending")]
|
||||
}
|
||||
|
||||
private func items(_ type: String, _ params: [(String, Any?)], parent: String? = nil, start: Int? = nil, limit: Int? = nil) async throws -> JList {
|
||||
try await get("/Items", [("userId", uid), ("IncludeItemTypes", type), ("Recursive", "true"), ("Fields", Self.fields),
|
||||
("ParentId", parent), ("StartIndex", start), ("Limit", limit)] + params)
|
||||
}
|
||||
|
||||
/// Every chosen library's items, paged.
|
||||
private func pooled(_ type: String, _ params: [(String, Any?)]) async throws -> [JItem] {
|
||||
var out: [JItem] = []
|
||||
for sec in server.sections {
|
||||
var start = 0
|
||||
while true {
|
||||
let page = try await items(type, params, parent: sec, start: start, limit: Self.page)
|
||||
out += page.items
|
||||
start += page.items.count
|
||||
if page.items.isEmpty || start >= page.total { break }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func first(_ type: String, _ params: [(String, Any?)], count: Int) async throws -> [JItem] {
|
||||
var out: [JItem] = []
|
||||
for sec in server.sections { out += try await items(type, params, parent: sec, limit: count).items }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- sign-in --------------------------------------------------------------------
|
||||
|
||||
func login(user: String, password: String) async throws -> JellyfinLogin {
|
||||
var req = request(try url("/Users/AuthenticateByName"), method: "POST", token: "")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: ["Username": user, "Pw": password])
|
||||
let data = try await http.data(req)
|
||||
guard let o = try JSONSerialization.jsonObject(with: data) as? [String: Any], let t = o["AccessToken"] as? String,
|
||||
let u = o["User"] as? [String: Any], let id = u["Id"] as? String
|
||||
else { throw PlainError("Jellyfin didn't accept that sign-in") }
|
||||
return JellyfinLogin(token: t, userId: id, user: u["Name"] as? String ?? user, serverId: o["ServerId"] as? String ?? "")
|
||||
}
|
||||
|
||||
/// An API key from the dashboard has no user, so take the first user (admins only see that list).
|
||||
func loginWithKey(_ key: String) async throws -> JellyfinLogin {
|
||||
let data = try await http.data(request(try url("/Users"), token: key))
|
||||
guard let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], let u = list.first, let id = u["Id"] as? String
|
||||
else { throw PlainError("That API key didn't work on this server") }
|
||||
return JellyfinLogin(token: key, userId: id, user: u["Name"] as? String ?? "Jellyfin user", serverId: u["ServerId"] as? String ?? "")
|
||||
}
|
||||
|
||||
func musicSections() async throws -> [MusicSection] {
|
||||
let all: [JItem]
|
||||
do { all = try await get("/UserViews", [("userId", uid)]).items } catch { all = try await get("/Users/\(uid)/Views").items }
|
||||
let views = all.filter { $0.collectionType == "music" }
|
||||
// Folder paths need an admin; without them the picker just shows names.
|
||||
var paths: [String: [String]] = [:]
|
||||
if let data = try? await http.data(request(try url("/Library/VirtualFolders"))),
|
||||
let folders = try? JSONDecoder().decode([Lenient<JFolder>].self, from: data) {
|
||||
for f in folders.compactMap(\.value) { paths[f.id] = f.locations }
|
||||
}
|
||||
return views.map { MusicSection(key: $0.id, title: $0.name, locations: paths[$0.id] ?? []) }
|
||||
}
|
||||
|
||||
// ---- library ----------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String) async throws -> [Artist] {
|
||||
var out: [Artist] = []
|
||||
for sec in server.sections {
|
||||
out += try await get("/Artists/AlbumArtists", [("userId", uid), ("ParentId", sec), ("Fields", "SortName")] + sortParams(sort))
|
||||
.items.map { $0.toArtist() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func albums(sort: String) async throws -> [Album] {
|
||||
try await pooled("MusicAlbum", sortParams(sort)).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int) async throws -> [Album] {
|
||||
try await first("MusicAlbum", sortParams("addedAt:desc"), count: count).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
/// Albums of the songs played last, newest first.
|
||||
func recentlyPlayed(count: Int) async throws -> [Album] {
|
||||
let played = try await first("Audio", sortParams("lastViewedAt:desc") + [("Filters", "IsPlayed")], count: count * 4)
|
||||
var seen = Set<String>()
|
||||
let ids = played.compactMap(\.albumId).filter { seen.insert($0).inserted }.prefix(count)
|
||||
guard !ids.isEmpty else { return [] }
|
||||
let list = try await get("/Items", [("userId", uid), ("Ids", ids.joined(separator: ",")), ("Fields", Self.fields)]).items
|
||||
return ids.compactMap { id in list.first { $0.id == id }?.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAddedTracks(count: Int) async throws -> [Track] {
|
||||
try await first("Audio", sortParams("addedAt:desc"), count: count).map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func randomAlbums(_ count: Int) async throws -> [Album] {
|
||||
Array(try await first("MusicAlbum", sortParams("random"), count: count).map { $0.toAlbum() }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) {
|
||||
var out: [Track] = []
|
||||
var offset = 0, total = 0
|
||||
for sec in server.sections {
|
||||
let count = try await items("Audio", [], parent: sec, limit: 0).total
|
||||
let from = max(start + out.count - offset, 0)
|
||||
if out.count < size, from < count {
|
||||
out += try await items("Audio", sortParams(sort, tracks: true), parent: sec, start: from, limit: size - out.count).items.map { $0.toTrack() }
|
||||
}
|
||||
offset += count; total += count
|
||||
}
|
||||
return (out, total)
|
||||
}
|
||||
|
||||
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||
let each = count / max(server.sections.count, 1) + 1
|
||||
return Array(try await first("Audio", sortParams("random"), count: each).map { $0.toTrack() }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
try await get("/Items", [("userId", uid), ("Ids", key), ("Fields", Self.fields)]).items.first?.toAlbum()
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await items("Audio", [("SortBy", "ParentIndexNumber,IndexNumber,SortName")], parent: key).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await items("MusicAlbum", [("AlbumArtistIds", key), ("SortBy", "ProductionYear,SortName")]).items.map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
try await items("Audio", [("ArtistIds", key), ("SortBy", "Album,ParentIndexNumber,IndexNumber")]).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
try await items("Audio", [("searchTerm", title), ("Limit", 50)]).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { nil }
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
async let ar = get("/Artists", [("userId", uid), ("searchTerm", query), ("Limit", 12)])
|
||||
async let al = items("MusicAlbum", [("searchTerm", query)], limit: 12)
|
||||
async let tr = items("Audio", [("searchTerm", query)], limit: 12)
|
||||
async let pl = items("Playlist", [("searchTerm", query)], limit: 12)
|
||||
return SearchResults(artists: try await ar.items.map { $0.toArtist() }, albums: try await al.items.map { $0.toAlbum() },
|
||||
tracks: try await tr.items.map { $0.toTrack() },
|
||||
playlists: try await pl.items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() })
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------------
|
||||
|
||||
func playlists(sort: String) async throws -> [Playlist] {
|
||||
try await items("Playlist", sortParams(sort)).items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
private func entries(_ key: String) async throws -> [JItem] {
|
||||
try await get("/Playlists/\(key)/Items", [("userId", uid), ("Fields", Self.fields)]).items
|
||||
}
|
||||
|
||||
/// Jellyfin's entry ids are strings, so a track's playlistItemId is its position.
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
try await entries(key).enumerated().map { $1.toTrack($0) }
|
||||
}
|
||||
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? {
|
||||
let data = try await send("POST", "/Playlists", body: ["Name": title, "Ids": trackKeys, "UserId": uid, "MediaType": "Audio"])
|
||||
guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let id = o["Id"] as? String else { return nil }
|
||||
return Playlist(ratingKey: id, title: title, trackCount: trackKeys.count)
|
||||
}
|
||||
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws {
|
||||
try await send("POST", "/Playlists/\(key)/Items", [("ids", trackKeys.joined(separator: ",")), ("userId", uid)])
|
||||
}
|
||||
|
||||
private func entryId(_ key: String, _ position: Int64) async throws -> String {
|
||||
let list = try await entries(key)
|
||||
guard position >= 0, position < list.count, let id = list[Int(position)].playlistItemId else { throw PlainError("That song moved; refresh the playlist") }
|
||||
return id
|
||||
}
|
||||
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws {
|
||||
let id = try await entryId(key, playlistItemId)
|
||||
try await send("DELETE", "/Playlists/\(key)/Items", [("entryIds", id)])
|
||||
}
|
||||
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws {
|
||||
let id = try await entryId(key, playlistItemId)
|
||||
// The new index counts the list without the moved song.
|
||||
let to = afterItemId.map { $0 < playlistItemId ? $0 + 1 : $0 } ?? 0
|
||||
try await send("POST", "/Playlists/\(key)/Items/\(id)/Move/\(to)")
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("POST", "/Playlists/\(key)", body: ["Name": title])
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/Items/\(key)")
|
||||
}
|
||||
|
||||
// ---- playback reporting ----------------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
if (try? await send("POST", "/UserPlayedItems/\(trackKey)", [("userId", uid)])) == nil {
|
||||
_ = try? await send("POST", "/Users/\(uid)/PlayedItems/\(trackKey)")
|
||||
}
|
||||
}
|
||||
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async {
|
||||
let path = state == "stopped" ? "/Sessions/Playing/Stopped" : "/Sessions/Playing/Progress"
|
||||
_ = try? await send("POST", path, body: ["ItemId": track.ratingKey, "PositionTicks": positionMs * 10_000,
|
||||
"IsPaused": state == "paused", "CanSeek": true])
|
||||
}
|
||||
|
||||
// ---- URLs ---------------------------------------------------------------------------
|
||||
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL? {
|
||||
guard let thumb, !thumb.isEmpty, server.signedIn, let u = URL(string: base + "/Items/\(thumb)/Images/Primary") else { return nil }
|
||||
return u.with([("fillWidth", px), ("fillHeight", px), ("quality", 90), ("api_key", server.token)])
|
||||
}
|
||||
|
||||
/// The original file, untouched, like Plex's part URL.
|
||||
func partUrl(_ partKey: String) -> URL? {
|
||||
URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token))
|
||||
}
|
||||
|
||||
/// True if [url] answers as a Jellyfin server.
|
||||
func probe() async -> Bool {
|
||||
guard let u = URL(string: base + "/System/Info/Public") else { return false }
|
||||
return await http.ok(request(u, token: ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// One music server the app reads from. The first one ever added has an empty [id], so its
|
||||
/// keys stay bare and everything saved before multi-server support (downloads, mixes, stem
|
||||
/// playlists) still matches. Every other server's keys carry "<id>~" in front.
|
||||
struct MediaServer: Codable, Hashable, Identifiable {
|
||||
enum Kind: String, Codable { case plex, jellyfin }
|
||||
|
||||
var id = ""
|
||||
var kind = Kind.plex
|
||||
var name = ""
|
||||
var url = ""
|
||||
var token = ""
|
||||
var user = ""
|
||||
/// Jellyfin user id; Plex leaves it empty.
|
||||
var userId = ""
|
||||
var machineId = ""
|
||||
var sections: [String] = []
|
||||
var sectionTitles = ""
|
||||
|
||||
var signedIn: Bool { !token.isEmpty }
|
||||
var ready: Bool { signedIn && !sections.isEmpty }
|
||||
var base: String { url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
var label: String { name.isEmpty ? (kind == .plex ? "Plex" : "Jellyfin") : name }
|
||||
var kindLabel: String { kind == .plex ? "Plex" : "Jellyfin" }
|
||||
|
||||
static func newId() -> String { "s" + String(UUID().uuidString.prefix(6)).lowercased() }
|
||||
}
|
||||
|
||||
/// What a single server can do. [PlexClient] pools these and tags their keys.
|
||||
protocol MediaSource: AnyObject, Sendable {
|
||||
var server: MediaServer { get }
|
||||
func artists(sort: String) async throws -> [Artist]
|
||||
func albums(sort: String) async throws -> [Album]
|
||||
func recentlyAdded(count: Int) async throws -> [Album]
|
||||
func recentlyPlayed(count: Int) async throws -> [Album]
|
||||
func recentlyAddedTracks(count: Int) async throws -> [Track]
|
||||
func randomAlbums(_ count: Int) async throws -> [Album]
|
||||
func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int)
|
||||
func randomTracks(_ count: Int) async throws -> [Track]
|
||||
func album(_ key: String) async throws -> Album?
|
||||
func albumTracks(_ key: String) async throws -> [Track]
|
||||
func artistAlbums(_ key: String) async throws -> [Album]
|
||||
func artistTracks(_ key: String) async throws -> [Track]
|
||||
func searchTracks(_ title: String) async throws -> [Track]
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]?
|
||||
func search(_ query: String) async throws -> SearchResults
|
||||
func playlists(sort: String) async throws -> [Playlist]
|
||||
func playlistTracks(_ key: String) async throws -> [Track]
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist?
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws
|
||||
func renamePlaylist(_ key: String, title: String) async throws
|
||||
func deletePlaylist(_ key: String) async throws
|
||||
func scrobble(_ trackKey: String) async
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL?
|
||||
func partUrl(_ partKey: String) -> URL?
|
||||
/// Music libraries on the server, for the picker in Settings.
|
||||
func musicSections() async throws -> [MusicSection]
|
||||
}
|
||||
|
||||
// ---- key tagging -----------------------------------------------------------------
|
||||
|
||||
extension String {
|
||||
func tagged(_ id: String) -> String { id.isEmpty ? self : id + "~" + self }
|
||||
}
|
||||
|
||||
extension Optional where Wrapped == String {
|
||||
func tagged(_ id: String) -> String? { map { $0.tagged(id) } }
|
||||
}
|
||||
|
||||
extension Track {
|
||||
func tagged(_ id: String) -> Track {
|
||||
guard !id.isEmpty else { return self }
|
||||
var t = self
|
||||
t.ratingKey = ratingKey.tagged(id); t.albumKey = albumKey.tagged(id); t.artistKey = artistKey.tagged(id)
|
||||
t.thumb = thumb.tagged(id); t.partKey = partKey.tagged(id)
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
extension Album {
|
||||
func tagged(_ id: String) -> Album {
|
||||
guard !id.isEmpty else { return self }
|
||||
var a = self
|
||||
a.ratingKey = ratingKey.tagged(id); a.artistKey = artistKey.tagged(id); a.thumb = thumb.tagged(id)
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
extension Artist {
|
||||
func tagged(_ id: String) -> Artist {
|
||||
guard !id.isEmpty else { return self }
|
||||
var a = self
|
||||
a.ratingKey = ratingKey.tagged(id); a.thumb = thumb.tagged(id)
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
extension Playlist {
|
||||
func tagged(_ id: String) -> Playlist {
|
||||
guard !id.isEmpty else { return self }
|
||||
var p = self
|
||||
p.ratingKey = ratingKey.tagged(id); p.thumb = thumb.tagged(id)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResults {
|
||||
func tagged(_ id: String) -> SearchResults {
|
||||
SearchResults(artists: artists.map { $0.tagged(id) }, albums: albums.map { $0.tagged(id) },
|
||||
tracks: tracks.map { $0.tagged(id) }, playlists: playlists.map { $0.tagged(id) })
|
||||
}
|
||||
}
|
||||
+160
-215
@@ -1,8 +1,7 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct NotSignedIn: LocalizedError {
|
||||
var errorDescription: String? { "Not signed in to Plex" }
|
||||
var errorDescription: String? { "Not signed in to a music server" }
|
||||
}
|
||||
|
||||
struct PlainError: LocalizedError {
|
||||
@@ -11,318 +10,264 @@ struct PlainError: LocalizedError {
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Plex Media Server and plex.tv calls. Library reads go through [http], whose cache
|
||||
/// answers from disk when the server can't be reached (see AppGraph).
|
||||
/// Every music server in Settings, pooled into one library. Listings run on each server and are
|
||||
/// joined in Settings order; anything keyed (an album, a track's file, a playlist) goes back to
|
||||
/// the server its key is tagged with (see MediaServer).
|
||||
final class PlexClient: @unchecked Sendable {
|
||||
static let product = "Stemify"
|
||||
private static let page = 1000
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
private let model: String
|
||||
private let osVersion: String
|
||||
private let lock = NSLock()
|
||||
private var builtFrom = "\u{0}"
|
||||
private var built: [MediaSource] = []
|
||||
|
||||
init(http: Http, settings: Settings) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
var sys = utsname()
|
||||
uname(&sys)
|
||||
let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) }
|
||||
model = machine.isEmpty ? "iPhone" : machine
|
||||
osVersion = ProcessInfo.processInfo.operatingSystemVersionString
|
||||
.replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? ""
|
||||
}
|
||||
|
||||
var signedIn: Bool { !settings.plexToken.isEmpty }
|
||||
private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private var token: String { settings.plexToken }
|
||||
/// The chosen music libraries; listings pool them, in the order they were picked.
|
||||
private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) }
|
||||
func make(_ s: MediaServer) -> MediaSource {
|
||||
s.kind == .plex ? PlexServer(http: http, settings: settings, server: s) : JellyfinClient(http: http, settings: settings, server: s)
|
||||
}
|
||||
|
||||
private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product")
|
||||
r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version")
|
||||
r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier")
|
||||
r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform")
|
||||
r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version")
|
||||
r.setValue(model, forHTTPHeaderField: "X-Plex-Device")
|
||||
r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name")
|
||||
if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") }
|
||||
/// Every signed-in server, rebuilt only when the saved list changes.
|
||||
var all: [MediaSource] {
|
||||
let json = settings.serversJson
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if json != builtFrom {
|
||||
built = settings.servers.filter(\.signedIn).map(make)
|
||||
builtFrom = json
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
/// Servers with at least one music library picked.
|
||||
private var sources: [MediaSource] { all.filter { $0.server.ready } }
|
||||
|
||||
var signedIn: Bool { !all.isEmpty }
|
||||
|
||||
/// The server a tagged key belongs to, and the key as that server knows it.
|
||||
private func route(_ key: String) -> (MediaSource, String)? {
|
||||
let list = all
|
||||
if let i = key.firstIndex(of: "~"), let s = list.first(where: { !$0.server.id.isEmpty && $0.server.id == key[..<i] }) {
|
||||
return (s, String(key[key.index(after: i)...]))
|
||||
}
|
||||
return list.first { $0.server.id.isEmpty }.map { ($0, key) }
|
||||
}
|
||||
|
||||
private func routed(_ key: String) throws -> (MediaSource, String) {
|
||||
guard let r = route(key) else { throw NotSignedIn() }
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) throws -> MediaContainer {
|
||||
try JSONDecoder().decode(PlexResponse.self, from: data).container
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params))
|
||||
if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") }
|
||||
if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") }
|
||||
let body = try await http.data(req)
|
||||
if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() }
|
||||
return try decode(body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params), method: method)
|
||||
if method != "DELETE" { req.httpBody = Data() }
|
||||
let body = try await http.data(req)
|
||||
let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.hasPrefix("{") else { return MediaContainer() }
|
||||
return (try? decode(body)) ?? MediaContainer()
|
||||
}
|
||||
|
||||
/// Pages through a section listing; Plex caps a single response on big libraries.
|
||||
private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
var start = 0
|
||||
while true {
|
||||
let page = try await get(path, params, start: start, size: Self.page)
|
||||
out += page.metadata
|
||||
start += page.metadata.count
|
||||
let total = page.totalSize ?? page.size
|
||||
if page.metadata.isEmpty || start >= total { break }
|
||||
/// Runs [f] on every server at once and joins the results in Settings order.
|
||||
/// One server being down doesn't hide the others; it only fails when they all do.
|
||||
private func pool<T: Sendable>(_ f: @escaping @Sendable (MediaSource) async throws -> [T]) async throws -> [T] {
|
||||
let list = sources
|
||||
guard !list.isEmpty else { throw NotSignedIn() }
|
||||
if list.count == 1 { return try await f(list[0]) }
|
||||
var results = [[T]?](repeating: nil, count: list.count)
|
||||
var failure: Error?
|
||||
await withTaskGroup(of: (Int, Result<[T], Error>).self) { g in
|
||||
for (i, s) in list.enumerated() {
|
||||
g.addTask { do { return (i, .success(try await f(s))) } catch { return (i, .failure(error)) } }
|
||||
}
|
||||
for await (i, r) in g {
|
||||
switch r {
|
||||
case .success(let v): results[i] = v
|
||||
case .failure(let e): failure = e
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
if results.allSatisfy({ $0 == nil }), let failure { throw failure }
|
||||
return results.compactMap { $0 }.flatMap { $0 }
|
||||
}
|
||||
|
||||
/// Every section's listing, one after another.
|
||||
private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
for sec in sections { out += try await all("/library/sections/\(sec)/all", params) }
|
||||
return out
|
||||
private var many: Bool { sources.count > 1 }
|
||||
|
||||
/// Name sorts come back sorted per server; put the joined list back in order.
|
||||
private func byName<T>(_ list: [T], _ sort: String, _ name: (T) -> String) -> [T] {
|
||||
guard many, sort.hasPrefix("titleSort") else { return list }
|
||||
let desc = sort.hasPrefix("titleSort:desc")
|
||||
return list.sorted { (name($0).localizedStandardCompare(name($1)) == .orderedAscending) != desc }
|
||||
}
|
||||
|
||||
/// A page of a listing that runs on through each section in turn.
|
||||
private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) {
|
||||
if sections.count == 1 {
|
||||
let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size)
|
||||
return (c.metadata, c.totalSize ?? c.size)
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||
byName(try await pool { s in try await s.artists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.sortTitle)
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
byName(try await pool { s in try await s.albums(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title)
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
try await pool { s in try await s.recentlyAdded(count: count).map { $0.tagged(s.server.id) } }
|
||||
}
|
||||
|
||||
func recentlyPlayed(count: Int = 20) async throws -> [Album] {
|
||||
try await pool { s in try await s.recentlyPlayed(count: count).map { $0.tagged(s.server.id) } }
|
||||
}
|
||||
|
||||
func recentlyAddedTracks(count: Int = 20) async throws -> [Track] {
|
||||
try await pool { s in try await s.recentlyAddedTracks(count: count).map { $0.tagged(s.server.id) } }
|
||||
}
|
||||
|
||||
func randomAlbums(_ count: Int) async throws -> [Album] {
|
||||
Array(try await pool { s in try await s.randomAlbums(count).map { $0.tagged(s.server.id) } }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
/// One page of every track, running on through each server in turn, and the total count.
|
||||
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||
let list = sources
|
||||
guard !list.isEmpty else { throw NotSignedIn() }
|
||||
if list.count == 1 {
|
||||
let (page, total) = try await list[0].tracksPage(start: start, size: size, sort: sort)
|
||||
return (page.map { $0.tagged(list[0].server.id) }, total)
|
||||
}
|
||||
var out: [PlexItem] = []
|
||||
var out: [Track] = []
|
||||
var offset = 0, total = 0
|
||||
for sec in sections {
|
||||
let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0)
|
||||
let count = n.totalSize ?? n.size
|
||||
for s in list {
|
||||
let count = try await s.tracksPage(start: 0, size: 0, sort: sort).1
|
||||
let from = max(start + out.count - offset, 0)
|
||||
if out.count < size, from < count {
|
||||
out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata
|
||||
out += try await s.tracksPage(start: from, size: size - out.count, sort: sort).0.map { $0.tagged(s.server.id) }
|
||||
}
|
||||
offset += count; total += count
|
||||
}
|
||||
return (out, total)
|
||||
}
|
||||
|
||||
// ---- server -------------------------------------------------------------------
|
||||
|
||||
func machineId() async throws -> String {
|
||||
let body = try await http.data(request(try url("/identity"), withToken: false))
|
||||
guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") }
|
||||
return id
|
||||
}
|
||||
|
||||
func musicSections() async throws -> [MusicSection] {
|
||||
try await get("/library/sections").directories
|
||||
.filter { $0.type == "artist" }
|
||||
.map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) }
|
||||
}
|
||||
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||
try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
var out: [Album] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// One page of every track. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||
let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size)
|
||||
return (page.map { $0.toTrack() }, total)
|
||||
}
|
||||
|
||||
/// Random tracks from the whole library, for shuffling it without loading every page.
|
||||
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||
var out: [Track] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1)
|
||||
.metadata.map { $0.toTrack() }
|
||||
}
|
||||
return Array(out.shuffled().prefix(count))
|
||||
let each = count / max(sources.count, 1) + 1
|
||||
return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.album(k)?.tagged(s.server.id)
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.albumTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
|
||||
// albums (an artist with only "The Best of Kansas" came back empty).
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.artistAlbums(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.artistTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
func track(_ key: String) async throws -> PlexItem? {
|
||||
try await get("/library/metadata/\(key)").metadata.first
|
||||
}
|
||||
|
||||
/// Title search inside the music section; used to give stem-only entries Plex metadata.
|
||||
/// Title search on every server; used to give stem-only entries library metadata.
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
var out: [Track] = []
|
||||
for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } }
|
||||
return out
|
||||
try await pool { s in try await s.searchTracks(title).map { $0.tagged(s.server.id) } }
|
||||
}
|
||||
|
||||
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB.
|
||||
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. Jellyfin has none.
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? {
|
||||
guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id
|
||||
else { return nil }
|
||||
let levels: [PlexLevel]
|
||||
do {
|
||||
levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels
|
||||
} catch is HttpError {
|
||||
return nil // not analysed yet
|
||||
}
|
||||
return levels.isEmpty ? nil : levels.map(\.v)
|
||||
let (s, k) = try routed(trackKey)
|
||||
return try await s.loudness(k, samples: samples)
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs
|
||||
func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] }
|
||||
return SearchResults(
|
||||
artists: hub("artist").map { $0.toArtist() },
|
||||
albums: hub("album").map { $0.toAlbum() },
|
||||
tracks: hub("track").map { $0.toTrack() },
|
||||
playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() }
|
||||
)
|
||||
let parts = try await pool { s in [try await s.search(query).tagged(s.server.id)] }
|
||||
return SearchResults(artists: parts.flatMap(\.artists), albums: parts.flatMap(\.albums),
|
||||
tracks: parts.flatMap(\.tracks), playlists: parts.flatMap(\.playlists))
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------
|
||||
|
||||
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
|
||||
byName(try await pool { s in try await s.playlists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title)
|
||||
}
|
||||
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
try await all("/playlists/\(key)/items").map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.playlistTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
private func libraryUri(_ keys: [String]) async throws -> String {
|
||||
var machine = settings.plexMachineId
|
||||
if machine.isEmpty {
|
||||
machine = try await machineId()
|
||||
settings.plexMachineId = machine
|
||||
}
|
||||
return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))"
|
||||
/// The keys that live on [s], untagged. A playlist can't hold another server's songs.
|
||||
private func keys(_ trackKeys: [String], on s: MediaSource) -> [String] {
|
||||
trackKeys.compactMap { k in route(k).flatMap { $0.0 === s ? $0.1 : nil } }
|
||||
}
|
||||
|
||||
/// Made on the server of the first song; songs from other servers are left out.
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)])
|
||||
.metadata.first?.toPlaylist()
|
||||
guard let first = trackKeys.first else { return nil }
|
||||
let (s, _) = try routed(first)
|
||||
return try await s.createPlaylist(title, trackKeys: keys(trackKeys, on: s))?.tagged(s.server.id)
|
||||
}
|
||||
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
try await send("PUT", "/playlists/\(key)/items", [("uri", uri)])
|
||||
let (s, k) = try routed(key)
|
||||
let own = keys(trackKeys, on: s)
|
||||
guard !own.isEmpty else { throw PlainError("Those songs are on a different server than this playlist") }
|
||||
try await s.addToPlaylist(k, trackKeys: own)
|
||||
}
|
||||
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)")
|
||||
let (s, k) = try routed(key)
|
||||
try await s.removeFromPlaylist(k, playlistItemId: playlistItemId)
|
||||
}
|
||||
|
||||
/// Moves an item to just after [afterItemId], or to the top when it's nil.
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws {
|
||||
try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)])
|
||||
let (s, k) = try routed(key)
|
||||
try await s.movePlaylistItem(k, playlistItemId: playlistItemId, afterItemId: afterItemId)
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("PUT", "/playlists/\(key)", [("title", title)])
|
||||
let (s, k) = try routed(key)
|
||||
try await s.renamePlaylist(k, title: title)
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)")
|
||||
let (s, k) = try routed(key)
|
||||
try await s.deletePlaylist(k)
|
||||
}
|
||||
|
||||
// ---- playback reporting ---------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
_ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")])
|
||||
guard let r = route(trackKey) else { return }
|
||||
let (s, k) = r
|
||||
await s.scrobble(k)
|
||||
}
|
||||
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async {
|
||||
_ = try? await get("/:/timeline", [
|
||||
("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"),
|
||||
("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1),
|
||||
])
|
||||
guard let r = route(track.ratingKey) else { return }
|
||||
let (s, k) = r
|
||||
var t = track
|
||||
t.ratingKey = k
|
||||
await s.timeline(t, state: state, positionMs: positionMs)
|
||||
}
|
||||
|
||||
// ---- URLs -----------------------------------------------------------------------
|
||||
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL? {
|
||||
guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil }
|
||||
return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)])
|
||||
guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil }
|
||||
let (s, k) = r
|
||||
return s.artUrl(k, px: px)
|
||||
}
|
||||
|
||||
func partUrl(_ partKey: String) -> URL? {
|
||||
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
|
||||
guard let r = route(partKey) else { return nil }
|
||||
let (s, k) = r
|
||||
return s.partUrl(k)
|
||||
}
|
||||
|
||||
// ---- plex.tv sign-in ------------------------------------------------------------
|
||||
// ---- sign-in helpers --------------------------------------------------------------
|
||||
|
||||
func createPin() async throws -> PlexPin {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST")
|
||||
req.httpBody = Data()
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
/// A Plex client for plex.tv calls and for a server that isn't saved yet.
|
||||
func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) }
|
||||
|
||||
func checkPin(_ id: Int64) async throws -> PlexPin {
|
||||
let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false)
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
func user(token: String) async throws -> PlexUser {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
/// True if [url] answers as a Plex server with the given token.
|
||||
func probe(_ url: String, token: String) async -> Bool {
|
||||
let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let u = URL(string: trimmed + "/library/sections") else { return false }
|
||||
var req = request(u, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return await http.ok(req)
|
||||
}
|
||||
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// One Plex Media Server, plus the plex.tv calls. Library reads go through [http], whose cache
|
||||
/// answers from disk when the server can't be reached (see AppGraph).
|
||||
final class PlexServer: MediaSource, @unchecked Sendable {
|
||||
static let product = "Stemify"
|
||||
private static let page = 1000
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
let server: MediaServer
|
||||
private var machine: String
|
||||
private let model: String
|
||||
private let osVersion: String
|
||||
|
||||
init(http: Http, settings: Settings, server: MediaServer) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
self.server = server
|
||||
machine = server.machineId
|
||||
var sys = utsname()
|
||||
uname(&sys)
|
||||
let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) }
|
||||
model = machine.isEmpty ? "iPhone" : machine
|
||||
osVersion = ProcessInfo.processInfo.operatingSystemVersionString
|
||||
.replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? ""
|
||||
}
|
||||
|
||||
var signedIn: Bool { server.signedIn }
|
||||
private var base: String { server.base }
|
||||
private var token: String { server.token }
|
||||
/// The chosen music libraries; listings pool them, in the order they were picked.
|
||||
private var sections: [String] { server.sections }
|
||||
|
||||
private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product")
|
||||
r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version")
|
||||
r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier")
|
||||
r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform")
|
||||
r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version")
|
||||
r.setValue(model, forHTTPHeaderField: "X-Plex-Device")
|
||||
r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name")
|
||||
if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) throws -> MediaContainer {
|
||||
try JSONDecoder().decode(PlexResponse.self, from: data).container
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params))
|
||||
if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") }
|
||||
if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") }
|
||||
let body = try await http.data(req)
|
||||
if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() }
|
||||
return try decode(body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params), method: method)
|
||||
if method != "DELETE" { req.httpBody = Data() }
|
||||
let body = try await http.data(req)
|
||||
let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.hasPrefix("{") else { return MediaContainer() }
|
||||
return (try? decode(body)) ?? MediaContainer()
|
||||
}
|
||||
|
||||
/// Pages through a section listing; Plex caps a single response on big libraries.
|
||||
private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
var start = 0
|
||||
while true {
|
||||
let page = try await get(path, params, start: start, size: Self.page)
|
||||
out += page.metadata
|
||||
start += page.metadata.count
|
||||
let total = page.totalSize ?? page.size
|
||||
if page.metadata.isEmpty || start >= total { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Every section's listing, one after another.
|
||||
private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
for sec in sections { out += try await all("/library/sections/\(sec)/all", params) }
|
||||
return out
|
||||
}
|
||||
|
||||
/// A page of a listing that runs on through each section in turn.
|
||||
private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) {
|
||||
if sections.count == 1 {
|
||||
let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size)
|
||||
return (c.metadata, c.totalSize ?? c.size)
|
||||
}
|
||||
var out: [PlexItem] = []
|
||||
var offset = 0, total = 0
|
||||
for sec in sections {
|
||||
let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0)
|
||||
let count = n.totalSize ?? n.size
|
||||
let from = max(start + out.count - offset, 0)
|
||||
if out.count < size, from < count {
|
||||
out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata
|
||||
}
|
||||
offset += count; total += count
|
||||
}
|
||||
return (out, total)
|
||||
}
|
||||
|
||||
// ---- server -------------------------------------------------------------------
|
||||
|
||||
func machineId() async throws -> String {
|
||||
let body = try await http.data(request(try url("/identity"), withToken: false))
|
||||
guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") }
|
||||
return id
|
||||
}
|
||||
|
||||
func musicSections() async throws -> [MusicSection] {
|
||||
try await get("/library/sections").directories
|
||||
.filter { $0.type == "artist" }
|
||||
.map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) }
|
||||
}
|
||||
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||
try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
var out: [Album] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Albums played most recently first.
|
||||
func recentlyPlayed(count: Int = 20) async throws -> [Album] {
|
||||
var out: [Album] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "lastViewedAt:desc"), ("viewCount>>", 0)], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Songs added most recently first.
|
||||
func recentlyAddedTracks(count: Int = 20) async throws -> [Track] {
|
||||
var out: [Track] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toTrack() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomAlbums(_ count: Int) async throws -> [Album] {
|
||||
var out: [Album] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "random")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
return Array(out.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
/// One page of every track. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||
let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size)
|
||||
return (page.map { $0.toTrack() }, total)
|
||||
}
|
||||
|
||||
/// Random tracks from the whole library, for shuffling it without loading every page.
|
||||
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||
var out: [Track] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1)
|
||||
.metadata.map { $0.toTrack() }
|
||||
}
|
||||
return Array(out.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
|
||||
// albums (an artist with only "The Best of Kansas" came back empty).
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func track(_ key: String) async throws -> PlexItem? {
|
||||
try await get("/library/metadata/\(key)").metadata.first
|
||||
}
|
||||
|
||||
/// Title search inside the music section; used to give stem-only entries Plex metadata.
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
var out: [Track] = []
|
||||
for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB.
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? {
|
||||
guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id
|
||||
else { return nil }
|
||||
let levels: [PlexLevel]
|
||||
do {
|
||||
levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels
|
||||
} catch is HttpError {
|
||||
return nil // not analysed yet
|
||||
}
|
||||
return levels.isEmpty ? nil : levels.map(\.v)
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs
|
||||
func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] }
|
||||
return SearchResults(
|
||||
artists: hub("artist").map { $0.toArtist() },
|
||||
albums: hub("album").map { $0.toAlbum() },
|
||||
tracks: hub("track").map { $0.toTrack() },
|
||||
playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() }
|
||||
)
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------
|
||||
|
||||
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
try await all("/playlists/\(key)/items").map { $0.toTrack() }
|
||||
}
|
||||
|
||||
private func libraryUri(_ keys: [String]) async throws -> String {
|
||||
if machine.isEmpty { machine = try await machineId() }
|
||||
return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))"
|
||||
}
|
||||
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)])
|
||||
.metadata.first?.toPlaylist()
|
||||
}
|
||||
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
try await send("PUT", "/playlists/\(key)/items", [("uri", uri)])
|
||||
}
|
||||
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)")
|
||||
}
|
||||
|
||||
/// Moves an item to just after [afterItemId], or to the top when it's nil.
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws {
|
||||
try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)])
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("PUT", "/playlists/\(key)", [("title", title)])
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)")
|
||||
}
|
||||
|
||||
// ---- playback reporting ---------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
_ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")])
|
||||
}
|
||||
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async {
|
||||
_ = try? await get("/:/timeline", [
|
||||
("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"),
|
||||
("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1),
|
||||
])
|
||||
}
|
||||
|
||||
// ---- URLs -----------------------------------------------------------------------
|
||||
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL? {
|
||||
guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil }
|
||||
return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)])
|
||||
}
|
||||
|
||||
func partUrl(_ partKey: String) -> URL? {
|
||||
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
|
||||
}
|
||||
|
||||
// ---- plex.tv sign-in ------------------------------------------------------------
|
||||
|
||||
func createPin() async throws -> PlexPin {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST")
|
||||
req.httpBody = Data()
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
func checkPin(_ id: Int64) async throws -> PlexPin {
|
||||
let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false)
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
func user(token: String) async throws -> PlexUser {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
/// True if [url] answers as a Plex server with the given token.
|
||||
func probe(_ url: String, token: String) async -> Bool {
|
||||
let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let u = URL(string: trimmed + "/library/sections") else { return false }
|
||||
var req = request(u, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return await http.ok(req)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1 @@
|
||||
{"images":[{"filename":"AvatarChris.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
{"images":[{"filename":"AvatarKevin.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||
@@ -38,14 +38,14 @@ final class StemCoverage: @unchecked Sendable {
|
||||
self.settings = settings
|
||||
file = dir.appendingPathComponent("library_tracks.json")
|
||||
listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) }
|
||||
settings.onChange("musicSection") { [weak self] in self?.sectionChanged() }
|
||||
settings.onChange("servers") { [weak self] in self?.sectionChanged() }
|
||||
stems.onIndexChange { [weak self] in self?.recompute() }
|
||||
sectionChanged()
|
||||
recompute()
|
||||
}
|
||||
|
||||
private func sectionChanged() {
|
||||
let section = settings.musicSection
|
||||
let section = settings.libraryKey
|
||||
guard !section.isEmpty else { return }
|
||||
if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return }
|
||||
Task { await refresh() }
|
||||
@@ -54,7 +54,7 @@ final class StemCoverage: @unchecked Sendable {
|
||||
/// Lists every track in the library again (every few hours).
|
||||
func refresh() async {
|
||||
await lock.run {
|
||||
let section = settings.musicSection
|
||||
let section = settings.libraryKey
|
||||
guard !section.isEmpty, plex.signedIn else { return }
|
||||
var all: [Slim] = []
|
||||
do {
|
||||
@@ -76,7 +76,7 @@ final class StemCoverage: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return }
|
||||
guard let l = listing, l.section == settings.libraryKey, stems.index.size > 0 else { return }
|
||||
let tracks = l.tracks
|
||||
Task.detached { [self] in
|
||||
var al: [String: (Int, Int)] = [:]
|
||||
|
||||
@@ -60,14 +60,14 @@ struct Artwork: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings.app-style coloured rounded-square icon.
|
||||
/// A settings icon: the glyph in its colour on a faint wash of the same colour.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let bg: UInt32
|
||||
var body: some View {
|
||||
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
|
||||
Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(Color(hex: bg))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
.background(Color(hex: bg).opacity(0.16), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ struct ErrorRow: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
var body: some View {
|
||||
if !AppGraph.shared.plex.signedIn {
|
||||
EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
EmptyState(icon: "server.rack", title: "Not Connected", message: "Add your Plex or Jellyfin server to see your music here.", action: "Connect") {
|
||||
ui.push(.Servers)
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
|
||||
/// offer the front or end of the queue, download, and add-to-playlist (playlists included).
|
||||
/// offer Play Next, Add to Queue, download (whole albums, artists and playlists, like Plexamp), and add-to-playlist.
|
||||
/// Each call returns the actions; follow-up choices go through [Overlays].
|
||||
@MainActor
|
||||
struct MediaMenu {
|
||||
@@ -11,9 +11,7 @@ struct MediaMenu {
|
||||
|
||||
func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
var a = queueActions { [t] }
|
||||
if g.downloads.records[t.ratingKey] == nil {
|
||||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") })
|
||||
} else {
|
||||
if g.downloads.looseRecords.contains(where: { $0.track.ratingKey == t.ratingKey }) {
|
||||
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]) })
|
||||
@@ -30,46 +28,44 @@ struct MediaMenu {
|
||||
}
|
||||
|
||||
func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.albums[al.ratingKey]
|
||||
let total = cov?.total ?? al.trackCount ?? Int.max
|
||||
var more: [SheetAction] = []
|
||||
if allowGoTo, let k = al.artistKey {
|
||||
more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) })
|
||||
}
|
||||
let plex = g.plex
|
||||
return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) },
|
||||
let gr = DownloadGroup(kind: .album, key: al.ratingKey, title: al.title, subtitle: al.artist, thumb: al.thumb)
|
||||
return collection("\(al.title) — \(al.artist)", gr, { try await plex.albumTracks(al.ratingKey) },
|
||||
playlistKey: nil, extra: more + extra, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func artist(_ ar: Artist) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.artists[ar.ratingKey]
|
||||
let plex = g.plex
|
||||
return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { try await plex.artistTracks(ar.ratingKey) },
|
||||
let gr = DownloadGroup(kind: .artist, key: ar.ratingKey, title: ar.title, subtitle: "Artist", thumb: ar.thumb)
|
||||
return collection(ar.title, gr, { try await plex.artistTracks(ar.ratingKey) },
|
||||
playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let plex = g.plex
|
||||
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,
|
||||
let gr = DownloadGroup(kind: .playlist, key: pl.ratingKey, title: pl.title, subtitle: "Playlist", thumb: pl.thumb)
|
||||
return collection(pl.title, gr, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
|
||||
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") {
|
||||
SheetAction("Play Next", 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") {
|
||||
SheetAction("Add to 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") })
|
||||
}
|
||||
a.append(downloadAction(pl.title, DownloadGroup(kind: .stemPlaylist, key: pl.id, title: pl.title, subtitle: "Stem Playlist", thumb: pl.thumb),
|
||||
{ pl.tracks }, confirm: false))
|
||||
if pl.items.isEmpty { a = [] }
|
||||
let lists = g.stemPlaylists
|
||||
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
|
||||
@@ -82,10 +78,10 @@ struct MediaMenu {
|
||||
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") {
|
||||
SheetAction("Play Next", 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") {
|
||||
SheetAction("Add to 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") {
|
||||
@@ -97,9 +93,11 @@ struct MediaMenu {
|
||||
|
||||
/// For songs already in hand (downloads with no Plex album behind them).
|
||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||
let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil }
|
||||
return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: extra, stemmed: stemmed)
|
||||
let remove = SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(tracks) }
|
||||
}
|
||||
return collection(title, nil, { tracks }, playlistKey: nil, extra: [remove] + extra, stemmed: stemmed)
|
||||
}
|
||||
|
||||
/// True when every song is known to have stems, so there's nothing for Stem Now to do.
|
||||
@@ -108,43 +106,46 @@ struct MediaMenu {
|
||||
return cov.withStems >= cov.total
|
||||
}
|
||||
|
||||
private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track],
|
||||
private func collection(_ title: String, _ group: DownloadGroup?, _ load: @escaping () async throws -> [Track],
|
||||
playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] {
|
||||
var a = queueActions(load)
|
||||
if !allDownloaded {
|
||||
a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") {
|
||||
withTracks(load) { tracks in
|
||||
let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") }
|
||||
if confirmDownload && tracks.count > 20 {
|
||||
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if !downloaded.isEmpty {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||
})
|
||||
}
|
||||
if let group { a.append(downloadAction(title, group, load, confirm: confirmDownload)) }
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
|
||||
if !stemmed { a.append(stemNow(load)) }
|
||||
return a + extra
|
||||
}
|
||||
|
||||
/// Front of the queue (right after this song) and the end of it.
|
||||
/// Play Next (right after this song) and Add to Queue (the end of it).
|
||||
private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] {
|
||||
[
|
||||
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }
|
||||
},
|
||||
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Download, or Remove Download once [group] is downloaded. A downloaded group keeps itself in step with the server.
|
||||
func downloadAction(_ title: String, _ group: DownloadGroup, _ load: @escaping () async throws -> [Track], confirm: Bool) -> SheetAction {
|
||||
if g.downloads.groups[group.id] != nil {
|
||||
return SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(group.id) }
|
||||
}
|
||||
}
|
||||
return SheetAction("Download", icon: "arrow.down.circle") {
|
||||
withTracks(load) { tracks in
|
||||
let go = { g.downloads.download(group, tracks); overlays.hud("Downloading", "arrow.down.circle") }
|
||||
if confirm && tracks.count > 20 {
|
||||
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Has stemd on the server separate the songs that lack stems ahead of the rest of the library.
|
||||
private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction {
|
||||
SheetAction("Stem Now", icon: "waveform") {
|
||||
@@ -189,6 +190,7 @@ struct MediaMenu {
|
||||
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
|
||||
}
|
||||
}
|
||||
for c in CustomPreset.load() { acts.append(SheetAction(c.name, icon: "slider.horizontal.3") { then(c.mix) }) }
|
||||
for p in MixPresets.all {
|
||||
guard let m = p.build(StemPlaylists.allStems) else { continue }
|
||||
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
|
||||
|
||||
@@ -87,6 +87,7 @@ struct AlbumGrid<Badge: View>: View {
|
||||
var showArtist = true
|
||||
var subtitle: ((Album) -> String?)?
|
||||
var menu: ((Album) -> [SheetAction])?
|
||||
var columns = 2
|
||||
let open: (Album) -> Void
|
||||
@ViewBuilder var badge: (Album) -> Badge
|
||||
|
||||
@@ -94,7 +95,9 @@ struct AlbumGrid<Badge: View>: View {
|
||||
|
||||
var body: some View {
|
||||
let cov = AppGraph.shared.coverage.albums
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 16, alignment: .top), GridItem(.flexible(), spacing: 16, alignment: .top)], spacing: 18) {
|
||||
let small = columns >= 4
|
||||
let gap: CGFloat = columns > 2 ? 10 : 16
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: gap, alignment: .top), count: columns), spacing: small ? 12 : 18) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
@@ -103,12 +106,12 @@ struct AlbumGrid<Badge: View>: View {
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
HStack(spacing: 6) {
|
||||
Text(a.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(a.title).font((small ? Font.caption : .subheadline).weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
StemFlag(cov: cov[a.ratingKey])
|
||||
}
|
||||
.padding(.top, 6)
|
||||
let sub = subtitle?(a) ?? (showArtist ? a.artist : a.year.map(String.init) ?? "")
|
||||
if !sub.isEmpty { Text(sub).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) }
|
||||
if !sub.isEmpty { Text(sub).font(small ? .caption : .subheadline).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { open(a) }
|
||||
@@ -120,8 +123,8 @@ struct AlbumGrid<Badge: View>: View {
|
||||
}
|
||||
|
||||
extension AlbumGrid where Badge == EmptyView {
|
||||
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, open: @escaping (Album) -> Void) {
|
||||
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, open: open) { _ in EmptyView() }
|
||||
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, columns: Int = 2, open: @escaping (Album) -> Void) {
|
||||
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, columns: columns, open: open) { _ in EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ enum Tab: String, CaseIterable, Hashable {
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar
|
||||
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar, WebRadio
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||
case Servers, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
|
||||
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ struct RootView: View {
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
.fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
.environment(ui)
|
||||
@@ -62,15 +62,20 @@ struct RootView: View {
|
||||
case .Genres: GenresScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
case .TabBar: TabBarScreen()
|
||||
case .WebRadio: WebRadioScreen()
|
||||
case .GenrePage(let g): GenreScreen(genre: g)
|
||||
case .Eq: EqScreen()
|
||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
||||
case .PlexAccount: PlexAccountScreen()
|
||||
case .Servers: ServersScreen()
|
||||
case .Server(let id): ServerScreen(id: id)
|
||||
case .StemServer: StemServerScreen()
|
||||
case .Quality: QualityScreen()
|
||||
case .Storage: StorageScreen()
|
||||
case .Appearance: AppearanceScreen()
|
||||
case .NowPlayingOptions: NowPlayingOptionsScreen()
|
||||
case .StemEditor: StemEditorScreen()
|
||||
case .About: AboutScreen()
|
||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||
|
||||
@@ -6,26 +6,34 @@ struct LibraryHome: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var played: [Album] = []
|
||||
@State private var added: [Track] = []
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty
|
||||
let ready = s.libraryReady
|
||||
let today = Date()
|
||||
Page(title: "Library") {
|
||||
if !ready {
|
||||
EmptyState(icon: "server.rack", title: "Connect to Plex",
|
||||
message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
EmptyState(icon: "server.rack", title: "Connect a Server",
|
||||
message: "Add your Plex or Jellyfin server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
|
||||
ui.push(.Servers)
|
||||
}
|
||||
}
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs),
|
||||
]
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { i, r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) }
|
||||
if ready {
|
||||
if !played.isEmpty {
|
||||
SectionTitle(title: "Recent Plays")
|
||||
RecentShelf(albums: played)
|
||||
}
|
||||
if !added.isEmpty {
|
||||
SectionTitle(title: "Recently Added")
|
||||
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) }
|
||||
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "On This Day")
|
||||
@@ -52,10 +60,48 @@ struct LibraryHome: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "Stations")
|
||||
let stations: [(String, String, () async throws -> [Track])] = [
|
||||
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }),
|
||||
("square.stack", "Random Album Radio", {
|
||||
var out: [Track] = []
|
||||
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) }
|
||||
return out
|
||||
}),
|
||||
]
|
||||
ForEach(stations, id: \.1) { st in
|
||||
LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) }
|
||||
}
|
||||
}
|
||||
// Every tab except Settings (and this one).
|
||||
SectionTitle(title: "Browse")
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres),
|
||||
("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio),
|
||||
]
|
||||
ForEach(rows, id: \.1) { r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) }
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.musicSection)|\(reload)") {
|
||||
.task(id: "\(s.libraryKey)|\(reload)") {
|
||||
guard ready else { return }
|
||||
async let rp = try? g.plex.recentlyPlayed(count: 24)
|
||||
async let ra = try? g.plex.recentlyAddedTracks(count: 10)
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
played = Array((await rp ?? []).prefix(24))
|
||||
added = Array((await ra ?? []).prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a station: its songs play in the order given (albums stay whole).
|
||||
private func play(_ load: @escaping () async throws -> [Track]) {
|
||||
Task {
|
||||
do {
|
||||
let list = try await load()
|
||||
if list.isEmpty { ui.overlays.hud("Nothing to play", "music.note") } else { g.playback.playTracks(list) }
|
||||
} catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +133,35 @@ private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday
|
||||
return (onDay, week)
|
||||
}
|
||||
|
||||
/// Recent plays as a two-row sideways shelf of small tiles, like Plexamp's, in iOS dress.
|
||||
private struct RecentShelf: View {
|
||||
let albums: [Album]
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private let size: CGFloat = 112
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: Array(repeating: GridItem(.fixed(size + 40), spacing: 14), count: albums.count > 4 ? 2 : 1), spacing: 12) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: size, corner: 8, placeholder: "square.stack")
|
||||
Text(a.title).font(.footnote.weight(.medium)).foregroundStyle(p.label).lineLimit(1).padding(.top, 5)
|
||||
Text(a.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
.frame(width: size, alignment: .topLeading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.push(.AlbumPage(a)) }
|
||||
.actionsMenu(ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@@ -236,6 +311,7 @@ struct SortMenu: View {
|
||||
} }
|
||||
Section {
|
||||
Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly)
|
||||
Toggle("No Stems", systemImage: "waveform.slash", isOn: Bindable(AppGraph.shared.settings).noStems)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
@@ -248,13 +324,15 @@ struct SortMenu: View {
|
||||
@MainActor
|
||||
enum StemsOnly {
|
||||
private static var g: AppGraph { .shared }
|
||||
static var on: Bool { g.settings.stemsOnly && g.stems.index.size > 0 }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { g.stems.find($0) != nil } : list }
|
||||
static var on: Bool { (g.settings.stemsOnly || g.settings.noStems) && g.stems.index.size > 0 }
|
||||
/// With No Stems the filter keeps what Stems Only would drop.
|
||||
private static var want: Bool { !g.settings.noStems }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { (g.stems.find($0) != nil) == want } : list }
|
||||
static func albums(_ list: [Album]) -> [Album] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
static func artists(_ list: [Artist]) -> [Artist] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +371,7 @@ struct ArtistsScreen: View {
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -352,17 +430,24 @@ struct AlbumsScreen: View {
|
||||
private let sort = SortState("albums", ALBUM_SORTS)
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
/// Pinch out for fewer, bigger albums; pinch in for more.
|
||||
@AppStorage("albumColumns") private var columns = 2
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Albums") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, columns: columns, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(MagnifyGesture().onEnded { v in
|
||||
let n = v.magnification < 0.85 ? min(columns + 1, 5) : v.magnification > 1.15 ? max(columns - 1, 2) : columns
|
||||
if n != columns { withAnimation(.spring(duration: 0.35)) { columns = n } }
|
||||
})
|
||||
.sensoryFeedback(.selection, trigger: columns)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -415,7 +500,7 @@ struct SongsScreen: View {
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") {
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
|
||||
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
|
||||
loadMore()
|
||||
}
|
||||
@@ -464,7 +549,7 @@ struct AlbumScreen: View {
|
||||
var body: some View {
|
||||
let a = detail ?? album
|
||||
let list = tracks.value ?? []
|
||||
let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true }
|
||||
let dl = g.downloads.group(.album, a.ratingKey)
|
||||
Page(title: a.title, large: false) {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
|
||||
@@ -503,12 +588,13 @@ struct AlbumScreen: View {
|
||||
if !list.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if allDownloaded {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) }
|
||||
if let dl {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(dl.id) }
|
||||
} else {
|
||||
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
g.downloads.download(DownloadGroup(kind: .album, key: a.ratingKey, title: a.title, subtitle: a.artist, thumb: a.thumb), list)
|
||||
ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
}
|
||||
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
|
||||
} label: { Image(systemName: dl != nil ? "checkmark.circle" : "arrow.down.circle") }
|
||||
MoreMenu(items: ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
@@ -551,137 +637,128 @@ struct ArtistScreen: View {
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------
|
||||
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Name"]
|
||||
|
||||
/// Downloaded songs gathered back into their albums.
|
||||
private struct DownloadedAlbum {
|
||||
let album: Album
|
||||
/// A downloaded playlist, album or artist with its songs' download state.
|
||||
private struct DownloadedGroup {
|
||||
let group: DownloadGroup
|
||||
let records: [DownloadRecord]
|
||||
let released: String?
|
||||
var added: Int64 { records.map(\.added).max() ?? 0 }
|
||||
var done: Int { records.filter(\.done).count }
|
||||
var failed: Int { records.filter { $0.error != nil }.count }
|
||||
var complete: Bool { done == records.count }
|
||||
var tracks: [Track] {
|
||||
records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) }
|
||||
}
|
||||
}
|
||||
|
||||
private func artistSortKey(_ s: String) -> String {
|
||||
let t = s.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t
|
||||
/// In the group's own order (playlist order, album track order).
|
||||
var tracks: [Track] { records.filter(\.done).map(\.track) }
|
||||
/// Shown through [AlbumGrid], so the key carries the group id.
|
||||
var tile: Album { Album(ratingKey: group.id, title: group.title, artist: group.subtitle, thumb: group.thumb) }
|
||||
}
|
||||
|
||||
/// Downloads the way Plexamp does them: whole playlists, albums and artists, kept in step with the server.
|
||||
struct DownloadsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
// Release days come from the album list (served from the HTTP cache when the server's away).
|
||||
@State private var info: [String: Album] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let records = g.downloads.records
|
||||
let sort = g.settings.downloadSort
|
||||
let albums = group(records, sort)
|
||||
let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } }
|
||||
let d = g.downloads
|
||||
let records = d.records
|
||||
let sort = min(g.settings.downloadSort, DOWNLOAD_SORTS.count - 1)
|
||||
let all = d.groups.values.map { gr in DownloadedGroup(group: gr, records: gr.trackKeys.compactMap { records[$0] }) }
|
||||
.sorted { sort == 1 ? $0.group.title.lowercased() < $1.group.title.lowercased() : $0.group.added > $1.group.added }
|
||||
let byId = Dictionary(all.map { ($0.group.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let loose = d.looseRecords.map(\.track)
|
||||
let allDone = records.values.filter(\.done).map(\.track)
|
||||
let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
|
||||
Page(title: "Downloads") {
|
||||
if albums.isEmpty {
|
||||
if all.isEmpty && loose.isEmpty {
|
||||
EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded",
|
||||
message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.")
|
||||
message: "Hold any playlist, album or artist and choose Download to play it, stems and all, without your server.")
|
||||
} else {
|
||||
Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
|
||||
Text("\(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
|
||||
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) })
|
||||
.padding(.bottom, 14)
|
||||
AlbumGrid(albums: albums.map(\.album), subtitle: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return a.artist }
|
||||
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
|
||||
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
|
||||
if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" }
|
||||
return a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return [] }
|
||||
var extra: [SheetAction] = []
|
||||
if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if !d.complete {
|
||||
extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) {
|
||||
g.downloads.remove(d.records.filter { !$0.done }.map(\.track))
|
||||
})
|
||||
ForEach([("Playlists", [DownloadGroup.Kind.playlist, .stemPlaylist]), ("Albums", [.album]), ("Artists", [.artist])], id: \.0) { title, kinds in
|
||||
let list = all.filter { kinds.contains($0.group.kind) }
|
||||
if !list.isEmpty {
|
||||
SectionTitle(title: title)
|
||||
grid(list, byId)
|
||||
}
|
||||
return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra)
|
||||
}, open: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return }
|
||||
if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) }
|
||||
}) { a in
|
||||
if let d = byKey[a.ratingKey], !d.complete {
|
||||
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
|
||||
ZStack {
|
||||
Circle().fill(Color.black.opacity(0.55))
|
||||
if d.failed > 0 {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
|
||||
} else {
|
||||
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
if !loose.isEmpty {
|
||||
SectionTitle(title: "Other Songs")
|
||||
ForEach(Array(loose.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(loose, start: i) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !albums.isEmpty {
|
||||
if !all.isEmpty || !loose.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in
|
||||
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
|
||||
})
|
||||
}
|
||||
MoreMenu(items: moreItems(albums, pinned))
|
||||
MoreMenu(items: moreItems(all, pinned))
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: g.settings.musicSection) {
|
||||
guard g.plex.signedIn else { return }
|
||||
if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) }
|
||||
.task { await g.downloads.refresh() }
|
||||
}
|
||||
|
||||
private func grid(_ list: [DownloadedGroup], _ byId: [String: DownloadedGroup]) -> some View {
|
||||
AlbumGrid(albums: list.map(\.tile), subtitle: { a in
|
||||
guard let d = byId[a.ratingKey] else { return a.artist }
|
||||
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
|
||||
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
|
||||
return a.artist == "Artist" || a.artist.hasSuffix("Playlist") ? "\(a.artist) · \(songs(d.records.count))" : a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byId[a.ratingKey] else { return [] }
|
||||
var items = [
|
||||
SheetAction("Play", icon: "play.fill") { g.playback.playTracks(d.tracks) },
|
||||
SheetAction("Shuffle", icon: "shuffle") { g.playback.playTracks(d.tracks, shuffle: true) },
|
||||
]
|
||||
if d.failed > 0 { items.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
items.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove Download?", "“\(d.group.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(d.group.id) }
|
||||
})
|
||||
return items
|
||||
}, open: { a in
|
||||
guard let gr = byId[a.ratingKey]?.group else { return }
|
||||
switch gr.kind {
|
||||
case .album: ui.push(.AlbumPage(Album(ratingKey: gr.key, title: gr.title, artist: gr.subtitle, thumb: gr.thumb)))
|
||||
case .artist: ui.push(.ArtistPage(Artist(ratingKey: gr.key, title: gr.title, sortTitle: gr.title, thumb: gr.thumb)))
|
||||
case .playlist: ui.push(.PlaylistPage(Playlist(ratingKey: gr.key, title: gr.title, thumb: gr.thumb, trackCount: gr.trackKeys.count)))
|
||||
case .stemPlaylist: ui.push(.StemPlaylistPage(gr.key))
|
||||
}
|
||||
}) { a in
|
||||
if let d = byId[a.ratingKey], !d.complete, !d.records.isEmpty {
|
||||
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
|
||||
ZStack {
|
||||
Circle().fill(Color.black.opacity(0.55))
|
||||
if d.failed > 0 {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
|
||||
} else {
|
||||
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
|
||||
private func moreItems(_ all: [DownloadedGroup], _ pinned: String) -> [SheetAction] {
|
||||
var a: [SheetAction] = []
|
||||
if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if all.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") {
|
||||
for id in g.downloads.groups.keys { g.downloads.removeGroup(id) }
|
||||
g.downloads.remove(g.downloads.records.values.map(\.track))
|
||||
}
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] {
|
||||
let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in
|
||||
let t = rs[0].track
|
||||
let known = info[key]
|
||||
let artists = Set(rs.map(\.track.artist))
|
||||
let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album,
|
||||
artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
|
||||
return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init))
|
||||
}
|
||||
switch sort {
|
||||
case 1:
|
||||
return grouped.sorted {
|
||||
(artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) <
|
||||
(artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased())
|
||||
}
|
||||
case 2:
|
||||
return grouped.sorted { a, b in
|
||||
(a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased()
|
||||
}
|
||||
default:
|
||||
return grouped.sorted { $0.added > $1.added }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- genres -----------------------------------------------------------------------------------
|
||||
@@ -713,7 +790,7 @@ struct GenresScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
@@ -731,7 +808,7 @@ struct GenreScreen: View {
|
||||
AlbumGrid(albums: StemsOnly.albums(list.filter { $0.genre == genre }), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The buttons under the transport on Now Playing that the user can hide.
|
||||
enum NowPlayingButton: String, CaseIterable, Identifiable {
|
||||
case Mixer, Speed, Route, AirPlay, Karaoke
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .Mixer: "Stem Mixer"
|
||||
case .Speed: "Speed"
|
||||
case .Route: "Output & EQ"
|
||||
case .AirPlay: "AirPlay"
|
||||
case .Karaoke: "Karaoke"
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .Mixer: "slider.vertical.3"
|
||||
case .Speed: "speedometer"
|
||||
case .Route: "hifispeaker"
|
||||
case .AirPlay: "airplayaudio"
|
||||
case .Karaoke: "music.mic"
|
||||
}
|
||||
}
|
||||
static func hidden(_ s: String) -> Set<NowPlayingButton> { Set(s.split(separator: ",").compactMap { Self(rawValue: String($0)) }) }
|
||||
}
|
||||
|
||||
struct NowPlayingOptionsScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
List {
|
||||
Section {
|
||||
ForEach(NowPlayingButton.allCases) { b in
|
||||
Toggle(isOn: Binding(
|
||||
get: { !NowPlayingButton.hidden(s.hiddenNowPlaying).contains(b) },
|
||||
set: { on in
|
||||
var h = NowPlayingButton.hidden(s.hiddenNowPlaying)
|
||||
if on { h.remove(b) } else { h.insert(b) }
|
||||
s.hiddenNowPlaying = h.map(\.rawValue).sorted().joined(separator: ",")
|
||||
})) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: b.icon, bg: 0x5AC8FA)
|
||||
Text(b.label).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
|
||||
Section {
|
||||
Toggle(isOn: $s.lockScreenMix) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "lock.fill", bg: 0xFF9500)
|
||||
Text("Back Button Changes Mix").foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Text("Lock Screen").foregroundStyle(p.label2) } footer: {
|
||||
Text("While stems play, the back button on the lock screen, Control Center and headphones moves to the next mix instead of the previous song, like the Mix button in CarPlay. The mix name shows after the artist.").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Now Playing")
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperCell: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let icon: String
|
||||
let iconBg: UInt32
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: 0...50) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: iconBg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
Spacer(minLength: 8)
|
||||
Text(value == 0 ? "Off" : "\(value) songs").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ struct NowPlayingScreen: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var panel = NpPanel.Art
|
||||
@State private var tint: Color?
|
||||
/// How far the screen is being dragged down to close it.
|
||||
@State private var drop: CGFloat = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@@ -130,11 +132,22 @@ struct NowPlayingScreen: View {
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.overlay(alignment: .top) {
|
||||
// Stands in for the sheet's grabber: tap or drag down to close.
|
||||
Capsule().fill(c.label3).frame(width: 36, height: 5)
|
||||
.frame(width: 120, height: 20).contentShape(Rectangle())
|
||||
.onTapGesture { dismiss() }
|
||||
.gesture(screenDrag)
|
||||
}
|
||||
.offset(y: drop)
|
||||
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
|
||||
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
|
||||
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
|
||||
.environment(\.palette, c)
|
||||
.preferredColorScheme(c.dark ? .dark : .light)
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(.clear)
|
||||
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
|
||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||
@@ -145,10 +158,10 @@ struct NowPlayingScreen: View {
|
||||
@ViewBuilder
|
||||
private func panelView(_ track: Track, header: Bool) -> some View {
|
||||
switch panel {
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle())
|
||||
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
|
||||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
|
||||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
|
||||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,21 +202,31 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next, hold: pm.nextAlbum)
|
||||
Spacer()
|
||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
}
|
||||
.frame(height: 84)
|
||||
.padding(.top, 14)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
|
||||
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } }
|
||||
if !hidden.contains(.Speed) { SpeedMenu() }
|
||||
if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() }
|
||||
if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) }
|
||||
if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } }
|
||||
}
|
||||
.frame(height: 48)
|
||||
// Swipe up on the art (or here) for the queue.
|
||||
Button { show(.Queue) } label: {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
|
||||
if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) }
|
||||
}
|
||||
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.gesture(swipe(up: panel != .Queue))
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
@@ -223,6 +246,30 @@ struct NowPlayingScreen: View {
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
||||
}
|
||||
|
||||
/// Swipe up for the queue, down to go back to the art.
|
||||
private func swipe(up: Bool) -> some Gesture {
|
||||
DragGesture(minimumDistance: 20).onEnded { v in
|
||||
let dy = v.translation.height
|
||||
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
|
||||
}
|
||||
}
|
||||
|
||||
private var screenDrag: some Gesture {
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onChanged { v in
|
||||
let dy = v.translation.height
|
||||
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0
|
||||
}
|
||||
.onEnded { v in
|
||||
let dy = v.translation.height
|
||||
let vertical = abs(dy) > abs(v.translation.width)
|
||||
if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
|
||||
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
|
||||
}
|
||||
}
|
||||
|
||||
private func routeLabel(_ c: Palette) -> some View {
|
||||
let eq = g.eq
|
||||
let device = eq.device
|
||||
@@ -263,12 +310,32 @@ private struct TransportButton: View {
|
||||
let symbol: String
|
||||
let size: CGFloat
|
||||
let action: () -> Void
|
||||
var hold: (() -> Void)?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HoldButton(action: action, hold: hold) {
|
||||
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
/// A PressFade button that runs [hold] on a long press instead of the tap (hold Next to skip the album).
|
||||
struct HoldButton<Label: View>: View {
|
||||
let action: () -> Void
|
||||
var hold: (() -> Void)?
|
||||
@ViewBuilder let label: () -> Label
|
||||
@State private var held = false
|
||||
@State private var fired = 0
|
||||
var body: some View {
|
||||
Button { if held { held = false } else { action() } } label: { label() }
|
||||
.buttonStyle(PressFade())
|
||||
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
|
||||
.onChanged { _ in held = false }
|
||||
.onEnded { _ in
|
||||
guard let hold else { return }
|
||||
held = true; fired += 1; hold()
|
||||
})
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: fired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +679,32 @@ private struct MixerPanel: View {
|
||||
if let saved, !isSaved {
|
||||
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
|
||||
}
|
||||
ForEach(MixPresets.all) { preset in
|
||||
PresetChip(text: "New Preset", icon: "plus", selected: false) {
|
||||
ui.overlays.prompt("Save Preset", "Saves this mix for every song.", "Save", placeholder: "Preset name") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
CustomPreset.add(n, pm.mix)
|
||||
ui.overlays.hud("Preset saved", "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
let s = g.settings
|
||||
ForEach(MixPresets.bar(s)) { preset in
|
||||
if let target = preset.build(names) {
|
||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||
let on = MixPresets.normalized(target) == current
|
||||
let locked = s.lockedPreset == preset.id
|
||||
// Tapping the mix that's on goes back to the full song.
|
||||
PresetChip(text: preset.label, icon: locked ? "lock.fill" : nil, selected: on) { pm.applyMix(on ? [:] : target) }
|
||||
.contextMenu {
|
||||
if locked {
|
||||
Button("Unlock", systemImage: "lock.open") { s.lockedPreset = "" }
|
||||
} else {
|
||||
Button("Lock for All Songs", systemImage: "lock") { s.lockedPreset = preset.id; pm.applyMix(target) }
|
||||
}
|
||||
Button("Edit Stems", systemImage: "slider.horizontal.3") { ui.showInSettings(.StemEditor) }
|
||||
if preset.custom {
|
||||
Button("Delete Preset", systemImage: "trash", role: .destructive) { CustomPreset.remove(preset.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,6 +728,8 @@ private struct MixerPanel: View {
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
// Holds still when everything fits, so a drag down closes Now Playing instead.
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +794,7 @@ private struct StemFader: View {
|
||||
|
||||
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
|
||||
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
|
||||
private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
let dot: Color?
|
||||
let label: String
|
||||
let percent: Int
|
||||
@@ -728,7 +820,7 @@ private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleBox: View {
|
||||
struct ToggleBox: View {
|
||||
let text: String
|
||||
let on: Bool
|
||||
let onColor: Color
|
||||
@@ -799,6 +891,7 @@ struct FaderSlider: View {
|
||||
// ---- queue ----------------------------------------------------------------------------------
|
||||
|
||||
private struct QueuePanel: View {
|
||||
var close: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
@@ -820,7 +913,9 @@ private struct QueuePanel: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 6)
|
||||
// Hold a row to drag it; swipe for Play Next or Remove.
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height > 50 { close() } })
|
||||
// Drag the handles to reorder; tap to jump, long press for Play Next or Remove.
|
||||
List {
|
||||
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
|
||||
HStack(spacing: 12) {
|
||||
@@ -830,7 +925,6 @@ private struct QueuePanel: View {
|
||||
Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Image(systemName: "line.3.horizontal").foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 60)
|
||||
.contentShape(Rectangle())
|
||||
@@ -838,12 +932,9 @@ private struct QueuePanel: View {
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
.contextMenu {
|
||||
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
|
||||
.tint(.indigo)
|
||||
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
@@ -851,7 +942,7 @@ private struct QueuePanel: View {
|
||||
pm.move(first + f, first + (to > f ? to - 1 : to))
|
||||
}
|
||||
if upcoming.isEmpty {
|
||||
Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.")
|
||||
Text("Nothing queued. Use Play Next or Add to Queue on any song.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
|
||||
@@ -859,6 +950,7 @@ private struct QueuePanel: View {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,10 +1069,9 @@ private struct MiniTransport: View {
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
if next {
|
||||
Button(action: pm.next) {
|
||||
HoldButton(action: pm.next, hold: pm.nextAlbum) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ struct PlaylistsScreen: View {
|
||||
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(g.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
// /playlists may not take sort=random, so Random shuffles here.
|
||||
let random = sort.option.random
|
||||
await LoadState.load($lists) { try await random ? g.plex.playlists().shuffled() : g.plex.playlists(sort: sort.param) }
|
||||
|
||||
@@ -102,7 +102,7 @@ private struct FieldCell: View {
|
||||
private struct Header: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
var body: some View { Text(text).font(.subheadline.weight(.semibold)).foregroundStyle(p.label2).textCase(nil) }
|
||||
}
|
||||
|
||||
private struct Footer: View {
|
||||
@@ -117,57 +117,246 @@ private func clean(_ url: String) -> String {
|
||||
return u
|
||||
}
|
||||
|
||||
/// Four little faders in the stem colours: the Settings mark.
|
||||
private struct StemMark: View {
|
||||
var scale: CGFloat = 1
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 3 * scale) {
|
||||
ForEach(Array(zip(["vocals", "drums", "bass", "other"], [18.0, 26, 14, 22])), id: \.0) { name, h in
|
||||
Capsule().fill(Color(hex: Stems.color(name))).frame(width: 5 * scale, height: h * scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One stop on the way from the servers to the phone. The rail runs through the dot and
|
||||
/// joins the rows above and below, so the three read as one path.
|
||||
private struct PathStop: View {
|
||||
let title: String
|
||||
let detail: String
|
||||
let color: UInt32
|
||||
let on: Bool
|
||||
var first = false
|
||||
var last = false
|
||||
let route: Route
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
NavigationLink(value: route) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(first ? Color.clear : p.separator).frame(width: 2)
|
||||
Rectangle().fill(last ? Color.clear : p.separator).frame(width: 2)
|
||||
}
|
||||
Circle().fill(on ? Color(hex: color) : p.cell)
|
||||
.overlay(Circle().strokeBorder(on ? Color.clear : p.label3, lineWidth: 2))
|
||||
.frame(width: 13, height: 13)
|
||||
}
|
||||
.frame(width: 30)
|
||||
.frame(maxHeight: .infinity)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.body.weight(.semibold)).foregroundStyle(p.label)
|
||||
Text(detail).font(.subheadline).foregroundStyle(on ? p.label2 : p.label3).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 64)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private let appVersion: String = {
|
||||
let info = Bundle.main.infoDictionary
|
||||
return "\(info?["CFBundleShortVersionString"] as? String ?? "1.1") (\(info?["CFBundleVersion"] as? String ?? "1"))"
|
||||
}()
|
||||
|
||||
// ---- settings -------------------------------------------------------------------------------
|
||||
|
||||
struct SettingsScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let status = g.stems.status
|
||||
List {
|
||||
// Everything on one screen: no footers but the last, one line each.
|
||||
// The path music takes: servers, then stems, then this phone.
|
||||
Section {
|
||||
NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle),
|
||||
icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount)
|
||||
NavCell(title: "Stem Server", value: {
|
||||
switch status {
|
||||
case .online(let n, _): "\(n) songs"
|
||||
case .offline: "Offline"
|
||||
case .unknown: "…"
|
||||
}
|
||||
}(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer)
|
||||
} header: { Header(text: "Servers") }
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
StemMark()
|
||||
Text("Stemify").font(.system(size: 30, weight: .heavy, design: .rounded)).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text(appVersion).font(.footnote.monospacedDigit()).foregroundStyle(p.label3).padding(.bottom, 4)
|
||||
}
|
||||
.padding(.top, 14).padding(.bottom, 6)
|
||||
.listRowSeparator(.hidden)
|
||||
.themedRow()
|
||||
PathStop(title: "Music Servers", detail: musicDetail(s), color: Stems.color("vocals"), on: s.libraryReady, first: true, route: .Servers)
|
||||
PathStop(title: "Stem Server", detail: stemDetail(status), color: Stems.color("drums"), on: status.isOnline, route: .StemServer)
|
||||
PathStop(title: "This \(UIDevice.current.model)", detail: "\(gb(g.cache.usage.cachedBytes)) cached" + (s.downloadsWifiOnly ? ", downloads on Wi-Fi" : ""),
|
||||
color: Stems.color("bass"), on: true, last: true, route: .Storage)
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Listening") }
|
||||
Section {
|
||||
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)
|
||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
} header: { Header(text: "Storage") }
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} header: { Header(text: "Sound and look") }
|
||||
Section {
|
||||
StepperCell(title: "Cache Ahead", value: $s.precacheCount, icon: "arrow.down.to.line", iconBg: 0xFF9500)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "wifi", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
BackupCells()
|
||||
} header: { Header(text: "Backup") } footer: {
|
||||
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
|
||||
} header: { Header(text: "On this phone") } footer: {
|
||||
Footer(text: "Export saves your stem playlists, their mixes and your sound settings to a file. Server addresses and sign-ins stay out of it.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "About", value: nil, icon: "info.circle.fill", iconBg: 0x8E8E93, route: .About)
|
||||
}
|
||||
Section {
|
||||
DebugCells()
|
||||
} header: { Header(text: "Debug") } footer: {
|
||||
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
|
||||
} header: { Header(text: "Troubleshooting") } footer: {
|
||||
Footer(text: "Verbose logging adds playback and network detail. Sign-in tokens are stripped from the log.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
ToggleCell(title: "Only Show Songs With Stems", icon: "waveform", iconBg: 0xAF52DE, isOn: $s.stemsOnly)
|
||||
} header: { Header(text: "Display") } footer: {
|
||||
Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz")
|
||||
VStack(spacing: 8) {
|
||||
StemMark(scale: 0.6).opacity(0.8)
|
||||
Text("Stemify \(appVersion)").font(.footnote.weight(.medium)).foregroundStyle(p.label2)
|
||||
Text("Made by Chris Nutter and Kevin Viers").font(.footnote).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(22)
|
||||
.themedList()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func musicDetail(_ s: Settings) -> String {
|
||||
let ready = s.servers.filter(\.ready)
|
||||
if s.servers.isEmpty { return "Add a Plex or Jellyfin server" }
|
||||
if ready.isEmpty { return "No libraries switched on" }
|
||||
return ready.map(\.label).joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func stemDetail(_ st: StemServerStatus) -> String {
|
||||
switch st {
|
||||
case .online(let n, _): "\(n.formatted()) songs with stems"
|
||||
case .offline(_, let cached): cached > 0 ? "Can't reach it, \(cached.formatted()) songs saved" : "Can't reach it"
|
||||
case .unknown: "Checking…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- about ----------------------------------------------------------------------------------
|
||||
|
||||
private struct Maintainer: Identifiable {
|
||||
let name: String
|
||||
let role: String
|
||||
let avatar: String
|
||||
let color: UInt32
|
||||
let site: (title: String, icon: String, value: String, url: String)
|
||||
let coffee: String?
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
private let maintainers = [
|
||||
Maintainer(name: "Chris Nutter", role: "Stemify WebUI and the iOS app", avatar: "AvatarChris", color: Stems.color("vocals"),
|
||||
site: ("Website", "globe", "iamchrisama.com", "https://iamchrisama.com"),
|
||||
coffee: "https://buymeacoffee.com/cdnutter"),
|
||||
Maintainer(name: "Kevin Viers", role: "stemd and the Android app", avatar: "AvatarKevin", color: Stems.color("drums"),
|
||||
site: ("GitHub", "chevron.left.forwardslash.chevron.right", "kevinViers", "https://github.com/kevinViers/"),
|
||||
coffee: nil),
|
||||
]
|
||||
|
||||
/// A maintainer's picture in a ring of their stem colour, their name and what they look after.
|
||||
private struct MaintainerCard: View {
|
||||
let m: Maintainer
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(m.avatar).resizable().interpolation(.high).aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(Circle())
|
||||
.padding(3)
|
||||
.overlay(Circle().strokeBorder(Color(hex: m.color), lineWidth: 2.5))
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(m.name).font(.title3.weight(.bold)).foregroundStyle(p.label)
|
||||
Text(m.role).font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.accessibilityElement(children: .combine)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 10) {
|
||||
StemMark(scale: 1.5)
|
||||
Text("Stemify").font(.system(size: 34, weight: .heavy, design: .rounded)).foregroundStyle(p.label)
|
||||
Text("Version \(appVersion)").font(.footnote.monospacedDigit()).foregroundStyle(p.label3)
|
||||
Text("Your Plex and Jellyfin music, split into stems you can mix.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ForEach(maintainers) { m in
|
||||
Section {
|
||||
MaintainerCard(m: m)
|
||||
Link(destination: URL(string: m.site.url)!) {
|
||||
ValueCell(title: m.site.title, value: m.site.value, icon: m.site.icon, iconBg: 0x007AFF)
|
||||
}
|
||||
.themedRow()
|
||||
if let coffee = m.coffee {
|
||||
Link(destination: URL(string: coffee)!) {
|
||||
ValueCell(title: "Buy Me a Coffee", value: nil, icon: "cup.and.saucer.fill", iconBg: 0xFF9500)
|
||||
}
|
||||
.themedRow()
|
||||
} else {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "cup.and.saucer.fill", bg: 0x8E8E93)
|
||||
Text("Buy Me a Coffee").foregroundStyle(p.label3)
|
||||
Spacer(minLength: 8)
|
||||
Text("Coming soon").foregroundStyle(p.label3)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
} footer: {
|
||||
Footer(text: "Stemify is free. A coffee goes straight to the person you pick.")
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(22)
|
||||
.themedList()
|
||||
.navigationTitle("About")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,103 +396,109 @@ struct BackupCells: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plex -----------------------------------------------------------------------------------
|
||||
// ---- music servers --------------------------------------------------------------------------
|
||||
|
||||
struct PlexAccountScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
/// Said on both server screens: stems are found by file path, so libraries have to line up.
|
||||
private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all."
|
||||
|
||||
struct ServersScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var url = AppGraph.shared.settings.plexUrl
|
||||
@State private var manual = ""
|
||||
@State private var pinCode: String?
|
||||
@State private var pinId: Int64 = 0
|
||||
@State private var pinError: String?
|
||||
@State private var sections: [MusicSection]?
|
||||
@State private var testing = false
|
||||
@State private var libs: [String: [MusicSection]] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let list = g.settings.servers
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400")
|
||||
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
||||
} header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") }
|
||||
if s.plexToken.isEmpty {
|
||||
Section {
|
||||
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
||||
if let pinCode {
|
||||
VStack(spacing: 0) {
|
||||
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
||||
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: {
|
||||
if let f = pinError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
||||
if list.isEmpty { Text("No servers yet").foregroundStyle(p.label2).themedRow() }
|
||||
ForEach(list) { s in
|
||||
NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"),
|
||||
icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id))
|
||||
}
|
||||
} header: { Header(text: "Music Servers") } footer: {
|
||||
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
|
||||
}
|
||||
ForEach(list.filter(\.signedIn)) { s in
|
||||
Section {
|
||||
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { useToken() }
|
||||
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
||||
} else {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "person.fill", bg: 0xE5A00D)
|
||||
Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label)
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Header(text: "Account") }
|
||||
Section {
|
||||
if let list = sections {
|
||||
if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() }
|
||||
ForEach(list) { sec in
|
||||
Button {
|
||||
// Tapping adds or drops a library; the picked ones are pooled. One always stays.
|
||||
var keys = s.musicSection.split(separator: ",").map(String.init)
|
||||
if let i = keys.firstIndex(of: sec.key) { if keys.count > 1 { keys.remove(at: i) } } else { keys.append(sec.key) }
|
||||
s.musicSection = keys.joined(separator: ",")
|
||||
s.musicSectionTitle = keys.compactMap { k in list.first { $0.key == k }?.title }.joined(separator: " + ")
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sec.title).foregroundStyle(p.label)
|
||||
if !sec.locations.isEmpty {
|
||||
Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
if let secs = libs[s.id] {
|
||||
if secs.isEmpty { Text("No music libraries").foregroundStyle(p.label2).themedRow() }
|
||||
ForEach(secs) { sec in
|
||||
ToggleCell(title: sec.title, icon: "music.note.house", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3,
|
||||
isOn: Binding(get: { s.sections.contains(sec.key) }, set: { on in set(s, sec, on, secs) }))
|
||||
}
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity).themedRow()
|
||||
}
|
||||
} header: { Header(text: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one library.") }
|
||||
} header: { Header(text: s.label + " Libraries") }
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex"))
|
||||
NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin"))
|
||||
} header: { Header(text: "Add") } footer: { Footer(text: matchNote) }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Music Servers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
for s in list where s.signedIn && libs[s.id] == nil {
|
||||
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns one library of a server on or off. All off keeps the sign-in.
|
||||
private func set(_ s: MediaServer, _ sec: MusicSection, _ on: Bool, _ secs: [MusicSection]) {
|
||||
var srv = s
|
||||
if on { if !srv.sections.contains(sec.key) { srv.sections.append(sec.key) } } else { srv.sections.removeAll { $0 == sec.key } }
|
||||
srv.sectionTitles = srv.sections.compactMap { k in secs.first { $0.key == k }?.title }.joined(separator: " + ")
|
||||
g.settings.save(srv)
|
||||
}
|
||||
}
|
||||
|
||||
/// One Plex or Jellyfin server: address and sign-in. Libraries are switched on in Music Servers.
|
||||
struct ServerScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var draft: MediaServer
|
||||
@State private var manual = ""
|
||||
@State private var user = ""
|
||||
@State private var password = ""
|
||||
@State private var pinCode: String?
|
||||
@State private var pinId: Int64 = 0
|
||||
@State private var signError: String?
|
||||
@State private var testing = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
init(id: String) {
|
||||
let s = AppGraph.shared.settings
|
||||
if let saved = s.servers.first(where: { $0.id == id }) {
|
||||
_draft = State(initialValue: saved)
|
||||
} else {
|
||||
_draft = State(initialValue: MediaServer(id: s.nextServerId(), kind: id == "new-jellyfin" ? .jellyfin : .plex))
|
||||
}
|
||||
}
|
||||
|
||||
private var plex: Bool { draft.kind == .plex }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Name", text: $draft.name, placeholder: draft.kindLabel)
|
||||
FieldCell(label: "Address", text: $draft.url, placeholder: plex ? "http://192.168.86.2:32400" : "http://192.168.86.2:8096")
|
||||
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
||||
} header: { Header(text: draft.kindLabel + " Server") } footer: {
|
||||
Footer(text: "The server's LAN address (port \(plex ? "32400" : "8096")). Add each server or IP as its own entry.")
|
||||
}
|
||||
if !draft.signedIn {
|
||||
if plex { plexSignIn } else { jellyfinSignIn }
|
||||
} else {
|
||||
Section {
|
||||
ActionCell(title: "Sign Out", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") {
|
||||
s.plexToken = ""
|
||||
s.plexUser = ""
|
||||
s.musicSection = ""
|
||||
s.musicSectionTitle = ""
|
||||
sections = nil
|
||||
ActionCell(title: "Remove Server", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Remove \(draft.label)?", "Downloads stay on this phone.", "Remove") {
|
||||
g.settings.removeServer(draft.id)
|
||||
ui.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,81 +506,157 @@ struct PlexAccountScreen: View {
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Plex")
|
||||
.navigationTitle(draft.label)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: s.plexToken) {
|
||||
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
|
||||
}
|
||||
.onChange(of: draft.name) { persist() }
|
||||
// Poll plex.tv while a PIN is showing.
|
||||
.task(id: pinId) {
|
||||
guard pinId != 0 else { return }
|
||||
for _ in 0..<300 {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
if let t = (try? await g.plex.checkPin(pinId))?.authToken, !t.isEmpty {
|
||||
if let t = (try? await g.plex.plexTv().checkPin(pinId))?.authToken, !t.isEmpty {
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
await finish(t)
|
||||
ui.overlays.hud("Signed In")
|
||||
await finishPlex(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
pinError = "The code expired. Try again."
|
||||
signError = "The code expired. Try again."
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var plexSignIn: some View {
|
||||
Section {
|
||||
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
||||
if let pinCode {
|
||||
VStack(spacing: 0) {
|
||||
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
||||
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: {
|
||||
if let f = signError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
||||
}
|
||||
Section {
|
||||
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { usePlexToken() }
|
||||
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
||||
}
|
||||
|
||||
@ViewBuilder private var jellyfinSignIn: some View {
|
||||
Section {
|
||||
FieldCell(label: "Username", text: $user, placeholder: "Required")
|
||||
FieldCell(label: "Password", text: $password, placeholder: "Optional", secret: true)
|
||||
ActionCell(title: "Sign In", enabled: !user.trimmingCharacters(in: .whitespaces).isEmpty) {
|
||||
jellyfinSignIn { try await $0.login(user: user.trimmingCharacters(in: .whitespaces), password: password) }
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: { if let signError { Footer(text: signError) } }
|
||||
Section {
|
||||
FieldCell(label: "API Key", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use API Key", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) {
|
||||
jellyfinSignIn { try await $0.loginWithKey(manual.trimmingCharacters(in: .whitespaces)) }
|
||||
}
|
||||
} header: { Header(text: "Or Use an API Key") } footer: { Footer(text: "Dashboard → API Keys on the Jellyfin server. It signs in as the first admin user.") }
|
||||
}
|
||||
|
||||
/// Saves the draft once it's signed in; before that there's nothing worth keeping.
|
||||
private func persist() {
|
||||
if draft.signedIn { g.settings.save(draft) }
|
||||
}
|
||||
|
||||
private func test() {
|
||||
let s = g.settings
|
||||
testing = true
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
let ok = await g.plex.probe(url, token: s.plexToken)
|
||||
let ok = plex ? await g.plex.plexTv(draft.url).probe(draft.url, token: draft.token) : await g.plex.jellyfin(draft).probe()
|
||||
testing = false
|
||||
if ok {
|
||||
s.plexUrl = clean(url)
|
||||
persist()
|
||||
ui.overlays.hud("Connected")
|
||||
} else {
|
||||
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
|
||||
ui.overlays.hud(plex && !draft.signedIn ? "Sign in first" : "Couldn't reach \(draft.kindLabel) at that address", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func newPin() {
|
||||
pinError = nil
|
||||
signError = nil
|
||||
Task {
|
||||
do {
|
||||
let pin = try await g.plex.createPin()
|
||||
let pin = try await g.plex.plexTv().createPin()
|
||||
pinCode = pin.code
|
||||
pinId = pin.id
|
||||
} catch {
|
||||
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
signError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useToken() {
|
||||
private func usePlexToken() {
|
||||
let t = manual.trimmingCharacters(in: .whitespaces)
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
if await g.plex.probe(url, token: t) { await finish(t) }
|
||||
if await g.plex.plexTv(draft.url).probe(draft.url, token: t) { await finishPlex(t) }
|
||||
else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ token: String) async {
|
||||
let s = g.settings
|
||||
s.plexUrl = clean(url)
|
||||
s.plexToken = token
|
||||
s.plexUser = await {
|
||||
guard let u = try? await g.plex.user(token: token) else { return "Plex user" }
|
||||
private func finishPlex(_ token: String) async {
|
||||
draft.url = clean(draft.url)
|
||||
let tv = g.plex.plexTv(draft.url)
|
||||
draft.user = await {
|
||||
guard let u = try? await tv.user(token: token) else { return "Plex user" }
|
||||
return u.title ?? u.username ?? "Plex user"
|
||||
}()
|
||||
s.plexMachineId = (try? await g.plex.machineId()) ?? ""
|
||||
sections = try? await g.plex.musicSections()
|
||||
if let only = sections, only.count == 1 {
|
||||
s.musicSection = only[0].key
|
||||
s.musicSectionTitle = only[0].title
|
||||
draft.machineId = (try? await tv.machineId()) ?? ""
|
||||
draft.token = token
|
||||
await finish()
|
||||
}
|
||||
|
||||
private func jellyfinSignIn(_ f: @escaping (JellyfinClient) async throws -> JellyfinLogin) {
|
||||
signError = nil
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
do {
|
||||
let l = try await f(g.plex.jellyfin(draft))
|
||||
draft.user = l.user
|
||||
draft.userId = l.userId
|
||||
draft.machineId = l.serverId
|
||||
draft.token = l.token
|
||||
password = ""
|
||||
await finish()
|
||||
} catch {
|
||||
signError = "Couldn't sign in: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed in: pick the only library if there's just one, and save. The rest are switched on
|
||||
/// in Music Servers.
|
||||
private func finish() async {
|
||||
manual = ""
|
||||
if let only = try? await g.plex.make(draft).musicSections(), only.count == 1 {
|
||||
draft.sections = [only[0].key]
|
||||
draft.sectionTitles = only[0].title
|
||||
}
|
||||
persist()
|
||||
ui.overlays.hud("Signed In")
|
||||
await g.stems.refresh()
|
||||
}
|
||||
}
|
||||
@@ -408,7 +679,7 @@ struct StemServerScreen: View {
|
||||
FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true)
|
||||
ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() }
|
||||
} header: { Header(text: "stemserve") } footer: {
|
||||
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.")
|
||||
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.\n\n" + matchNote)
|
||||
}
|
||||
Section {
|
||||
ValueCell(title: "Connection", value: {
|
||||
@@ -607,8 +878,16 @@ struct AppearanceScreen: View {
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||
Section {
|
||||
Toggle(isOn: $s.acapella) { Text("Spell It “Acapella”").foregroundStyle(c.label) }
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Mix Names") } footer: {
|
||||
Footer(text: "Shows the A Cappella mix as Acapella in the mixer, CarPlay, the lock screen and widgets.")
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
|
||||
NavigationLink(value: Route.NowPlayingOptions) { Text("Now Playing Buttons").foregroundStyle(c.label) }.themedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
@@ -622,11 +901,11 @@ struct AppearanceScreen: View {
|
||||
|
||||
struct TabBarScreen: View {
|
||||
@Environment(\.palette) private var c
|
||||
@State private var shown: [Tab] = Tab.layout(AppGraph.shared.settings.tabLayout)
|
||||
private func commit() { g.settings.tabLayout = Tab.encode(shown) }
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let shown = Tab.layout(s.tabLayout)
|
||||
let hidden = Tab.allCases.filter { !shown.contains($0) }
|
||||
List {
|
||||
Section {
|
||||
@@ -639,18 +918,20 @@ struct TabBarScreen: View {
|
||||
.deleteDisabled(t == .Settings)
|
||||
}
|
||||
.onMove { from, to in
|
||||
var l = shown
|
||||
l.move(fromOffsets: from, toOffset: to)
|
||||
s.tabLayout = Tab.encode(l)
|
||||
shown.move(fromOffsets: from, toOffset: to)
|
||||
commit()
|
||||
}
|
||||
.onDelete { idx in
|
||||
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
|
||||
let gone = Set(idx.map { shown[$0] }).subtracting([.Settings])
|
||||
shown.removeAll { gone.contains($0) }
|
||||
if shown.count < 2 { shown = Tab.defaultLayout }
|
||||
commit()
|
||||
}
|
||||
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
|
||||
if !hidden.isEmpty {
|
||||
Section {
|
||||
ForEach(hidden, id: \.self) { t in
|
||||
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
|
||||
Button { if shown.count < Tab.maxVisible { shown.append(t); commit() } } label: {
|
||||
HStack {
|
||||
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||
Spacer()
|
||||
@@ -663,7 +944,7 @@ struct TabBarScreen: View {
|
||||
} header: { Header(text: "Available") }
|
||||
}
|
||||
Section {
|
||||
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
|
||||
Button("Reset to default") { shown = Tab.defaultLayout; commit() }.themedRow()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The mixer bar's presets: order them, delete or add them, bring back deleted built-ins and unlock a locked one.
|
||||
/// This list is also the rotation for the Mix button in CarPlay, on the lock screen and in the widgets.
|
||||
struct StemEditorScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(Ui.self) private var ui
|
||||
/// The preset being made or edited; a blank one for New Preset.
|
||||
@State private var editing: CustomPreset?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let hidden = MixPresets.hidden(s)
|
||||
let order = MixPresets.ordered(s)
|
||||
let shown = order.filter { !hidden.contains($0.id) }
|
||||
let off = order.filter { hidden.contains($0.id) }
|
||||
let mine = shown.filter(\.custom).count
|
||||
List {
|
||||
if let l = MixPresets.locked(s) {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "lock.fill", bg: 0xFF9500)
|
||||
Text(l.label).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Button("Unlock") { s.lockedPreset = "" }
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Locked").foregroundStyle(p.label2) } footer: {
|
||||
Text("Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(shown) { pr in
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: pr.custom ? "person.fill" : "slider.horizontal.3", bg: pr.custom ? 0xAF52DE : 0x5AC8FA)
|
||||
Text(pr.label).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
if pr.custom { Image(systemName: "slider.vertical.3").foregroundStyle(p.label3) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if pr.custom { editing = CustomPreset.load(s).first { $0.name == pr.label } } }
|
||||
.themedRow()
|
||||
.deleteDisabled(pr == MixPresets.full)
|
||||
}
|
||||
.onMove { from, to in
|
||||
var l = shown
|
||||
l.move(fromOffsets: from, toOffset: to)
|
||||
MixPresets.setOrder(s, l + off)
|
||||
}
|
||||
.onDelete { rows in
|
||||
for pr in rows.map({ shown[$0] }) {
|
||||
if pr.custom { CustomPreset.remove(pr.label) } else { MixPresets.setHidden(s, pr.id, true) }
|
||||
if s.lockedPreset == pr.id { s.lockedPreset = "" }
|
||||
}
|
||||
}
|
||||
Button { editing = CustomPreset(name: "", mix: [:]) } label: { Label("New Preset", systemImage: "plus.circle.fill") }
|
||||
.themedRow()
|
||||
} header: { Text("Mixer Bar").foregroundStyle(p.label2) } footer: {
|
||||
Text("Drag to reorder and tap one of yours to change it. The Mix button in CarPlay, on the lock screen and in the widgets goes through this list in this order.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
Section {
|
||||
Button { MixPresets.restoreDefaults(s) } label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "arrow.counterclockwise", bg: 0x34C759)
|
||||
Text("Restore Deleted Presets").foregroundStyle(off.isEmpty ? p.label3 : p.label)
|
||||
Spacer()
|
||||
if !off.isEmpty { Text("\(off.count)").foregroundStyle(p.label2) }
|
||||
}
|
||||
}
|
||||
.disabled(off.isEmpty)
|
||||
.themedRow()
|
||||
Button {
|
||||
let what = mine == 1 ? "your preset" : "all \(mine) of your presets"
|
||||
ui.overlays.confirm("Delete \(what)?", "This can’t be undone. The built-in presets stay.", "Delete") {
|
||||
CustomPreset.removeAll()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "trash", bg: 0xFF3B30)
|
||||
Text("Delete All Custom Presets").foregroundStyle(mine == 0 ? p.label3 : p.red)
|
||||
}
|
||||
}
|
||||
.disabled(mine == 0)
|
||||
.themedRow()
|
||||
} footer: {
|
||||
Text(off.isEmpty ? "Every built-in preset is in the list." : "Brings back \(off.map(\.label).joined(separator: ", ")).")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle("Stem Editor")
|
||||
.sheet(item: $editing) { PresetSheet(original: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a preset on the same faders and M / S buttons as the Now Playing mixer, plus a name.
|
||||
private struct PresetSheet: View {
|
||||
let original: CustomPreset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.palette) private var p
|
||||
@State private var name = ""
|
||||
@State private var mix: [String: StemSetting] = [:]
|
||||
|
||||
var body: some View {
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
let anySolo = mix.values.contains { $0.solo }
|
||||
NavigationStack {
|
||||
List {
|
||||
Section { TextField("Preset name", text: $name).themedRow() }
|
||||
Section {
|
||||
ForEach(StemPlaylists.allStems, id: \.self) { st in
|
||||
let set = mix[st] ?? StemSetting()
|
||||
let silent = set.muted || (anySolo && !set.solo)
|
||||
let color = Color(hex: Stems.color(st))
|
||||
FaderRow(dot: silent ? p.label3 : color, label: Stems.label(st), percent: Int(stemTaper(set.level) * 100), dim: silent) {
|
||||
FaderSlider(value: set.level, fill: silent ? p.label3 : color, detent: StemSetting.unity) { mix[st, default: StemSetting()].level = $0 }
|
||||
} trailing: {
|
||||
ToggleBox(text: "M", on: set.muted, onColor: p.red) { mix[st, default: StemSetting()].muted.toggle() }
|
||||
ToggleBox(text: "S", on: set.solo, onColor: Color(hex: 0xFFCC00)) { mix[st, default: StemSetting()].solo.toggle() }
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Stems").foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
Button("Reset") { mix = [:] }.font(.footnote)
|
||||
}
|
||||
} footer: {
|
||||
Text("Stems a song doesn't have are skipped when the preset is applied.").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle(original.name.isEmpty ? "New Preset" : "Edit Preset")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let s = AppGraph.shared.settings
|
||||
let old = original.name
|
||||
let locked = !old.isEmpty && s.lockedPreset == "c:" + old
|
||||
if !old.isEmpty && old != n {
|
||||
CustomPreset.remove(old)
|
||||
s.presetOrder = s.presetOrder.split(separator: "\n").map { $0 == "c:" + old ? "c:" + n : String($0) }.joined(separator: "\n")
|
||||
}
|
||||
CustomPreset.add(n, mix)
|
||||
if locked { s.lockedPreset = "c:" + n }
|
||||
dismiss()
|
||||
}
|
||||
.disabled(n.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { name = original.name; mix = original.mix }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Saved internet radio stations. Tap to play, swipe to delete, long press to edit.
|
||||
struct WebRadioScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@State private var editing: WebStation?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let radio = g.radio
|
||||
let list = s.stations
|
||||
List {
|
||||
if let on = radio.station {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "antenna.radiowaves.left.and.right", bg: 0xFF2D55)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(on.name).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(radio.playing ? "Live" : "Paused").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer()
|
||||
Button { radio.toggle() } label: {
|
||||
Image(systemName: radio.playing ? "pause.fill" : "play.fill").font(.system(size: 20)).frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button { radio.stop() } label: {
|
||||
Image(systemName: "stop.fill").font(.system(size: 18)).frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Now Playing").foregroundStyle(p.label2) }
|
||||
}
|
||||
Section {
|
||||
ForEach(list) { st in
|
||||
Button { radio.play(st) } label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "dot.radiowaves.left.and.right", bg: 0x5856D6)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(st.name).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(st.url).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if radio.station?.id == st.id { PlayingBars(playing: radio.playing) }
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
.swipeActions(edge: .leading) { Button("Edit") { editing = st }.tint(.orange) }
|
||||
.contextMenu {
|
||||
Button { editing = st } label: { Label("Edit", systemImage: "pencil") }
|
||||
Button(role: .destructive) { remove(st) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
.onDelete { rows in rows.map { list[$0] }.forEach(remove) }
|
||||
Button { editing = WebStation(name: "", url: "") } label: { Label("Add Station", systemImage: "plus.circle.fill") }
|
||||
.themedRow()
|
||||
} header: { Text("Stations").foregroundStyle(p.label2) } footer: {
|
||||
Text("Any direct stream URL works (MP3, AAC or HLS). Playing a station pauses your queue, and playing a song stops the station.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Web Radio")
|
||||
.sheet(item: $editing) { st in StationSheet(station: st) }
|
||||
}
|
||||
|
||||
private func remove(_ st: WebStation) {
|
||||
if g.radio.station?.id == st.id { g.radio.stop() }
|
||||
g.settings.stations.removeAll { $0.id == st.id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Name and stream URL for a new or existing station.
|
||||
private struct StationSheet: View {
|
||||
let station: WebStation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
|
||||
var body: some View {
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
let u = url.trimmingCharacters(in: .whitespaces)
|
||||
let valid = !n.isEmpty && URL(string: u).map { ["http", "https"].contains($0.scheme?.lowercased() ?? "") } == true
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
TextField("Name", text: $name).themedRow()
|
||||
TextField("Stream URL", text: $url)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle(station.name.isEmpty ? "New Station" : "Edit Station")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let s = AppGraph.shared.settings
|
||||
var list = s.stations
|
||||
let st = WebStation(id: station.id, name: n, url: u)
|
||||
if let i = list.firstIndex(where: { $0.id == station.id }) { list[i] = st } else { list.append(st) }
|
||||
s.stations = list
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { name = station.name; url = station.url }
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# iOS to-do
|
||||
|
||||
Planned, not started.
|
||||
|
||||
1. Album shuffle: random order of whole albums, tracks in order inside each (like Plexamp).
|
||||
2. Customizable stem presets: let the user edit and remove presets (No Drums, Rhythm, etc.) and add their own.
|
||||
3. Bug: a saved mix's stem selection carries over to the next track.
|
||||
4. Continuous preloading: user sets how many songs to preload in Settings, up to 50.
|
||||
5. Let the user hide options on the now-playing screen.
|
||||
6. CarPlay stem change text: show only the mix type instead of "Mix: <type>".
|
||||
7. Add Genre to the Library tab.
|
||||
8. Remove the "Only show songs with stems" setting, since sorting already covers it.
|
||||
9. Add a "No Stems" sort option.
|
||||
10. Optional donate buttons in Settings.
|
||||
@@ -0,0 +1,342 @@
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// ---- look ---------------------------------------------------------------------------------
|
||||
|
||||
enum Look {
|
||||
static let bg = Color(light: 0xFFFFFF, dark: 0x151518)
|
||||
static let panel = Color(light: 0xEFEFF3, dark: 0x26262B)
|
||||
static let well = Color(light: 0xE2E2E8, dark: 0x34343A)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(red: Double(hex >> 16 & 0xFF) / 255, green: Double(hex >> 8 & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
|
||||
}
|
||||
|
||||
init(light: UInt32, dark: UInt32) {
|
||||
self.init(UIColor { t in UIColor(Color(hex: t.userInterfaceStyle == .dark ? dark : light)) })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pieces -------------------------------------------------------------------------------
|
||||
|
||||
/// A mixer knob: a 270° arc filled to the fader position, a tick at unity and a pointer.
|
||||
struct Knob: View {
|
||||
let stem: WidgetStem
|
||||
let silent: Bool
|
||||
let size: CGFloat
|
||||
|
||||
private var tint: Color { silent ? .secondary.opacity(0.5) : Color(hex: stem.color) }
|
||||
private var line: CGFloat { max(3, size * 0.085) }
|
||||
|
||||
var body: some View {
|
||||
let v = min(max(stem.level, 0), 1)
|
||||
ZStack {
|
||||
Circle().trim(from: 0, to: 0.75)
|
||||
.stroke(Look.well, style: StrokeStyle(lineWidth: line, lineCap: .round))
|
||||
.rotationEffect(.degrees(135))
|
||||
Circle().trim(from: 0, to: 0.75 * v)
|
||||
.stroke(tint, style: StrokeStyle(lineWidth: line, lineCap: .round))
|
||||
.rotationEffect(.degrees(135))
|
||||
.widgetAccentable()
|
||||
// Unity sits at 80% of the travel.
|
||||
Capsule().fill(.secondary.opacity(0.6))
|
||||
.frame(width: 1.5, height: line * 0.9)
|
||||
.offset(y: -size / 2 - line * 0.95)
|
||||
.rotationEffect(.degrees(270 * 0.8 - 135))
|
||||
Circle().fill(Look.panel)
|
||||
.frame(width: size * 0.66, height: size * 0.66)
|
||||
.overlay {
|
||||
Capsule().fill(tint)
|
||||
.frame(width: 2.5, height: size * 0.12)
|
||||
.offset(y: -size * 0.33 + size * 0.1)
|
||||
.rotationEffect(.degrees(270 * v - 135))
|
||||
.widgetAccentable()
|
||||
}
|
||||
Group {
|
||||
if silent {
|
||||
Image(systemName: "speaker.slash.fill").font(.system(size: size * 0.2, weight: .semibold))
|
||||
} else {
|
||||
Text("\(stem.percent)").font(.system(size: size * 0.2, weight: .semibold, design: .rounded)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(silent ? .secondary : .primary)
|
||||
.offset(y: size * 0.03)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.padding(line / 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// A knob you tap to mute, its name, and a − / + pair that turns it one step.
|
||||
struct KnobCell: View {
|
||||
let stem: WidgetStem
|
||||
let silent: Bool
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Button(intent: MuteStemIntent(stem: stem.name)) {
|
||||
Knob(stem: stem, silent: silent, size: size)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Text(stem.label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(silent ? .secondary : .primary)
|
||||
.lineLimit(1).minimumScaleFactor(0.7)
|
||||
HStack(spacing: 0) {
|
||||
step(-1, "minus")
|
||||
Rectangle().fill(.secondary.opacity(0.25)).frame(width: 1, height: 10)
|
||||
step(1, "plus")
|
||||
}
|
||||
.background(Capsule().fill(Look.panel))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func step(_ n: Int, _ icon: String) -> some View {
|
||||
Button(intent: NudgeStemIntent(stem: stem.name, steps: n)) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.frame(width: 24, height: 20)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(n < 0 ? stem.level <= 0.001 : stem.level >= 0.999)
|
||||
}
|
||||
}
|
||||
|
||||
struct Art: View {
|
||||
let image: UIImage?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
Look.panel.overlay {
|
||||
Image(systemName: "music.note").font(.system(size: size * 0.4)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary-coloured capsule that moves to the next mix.
|
||||
struct MixPill: View {
|
||||
let text: String
|
||||
var icon = "chevron.right"
|
||||
|
||||
var body: some View {
|
||||
Button(intent: CycleMixIntent()) {
|
||||
HStack(spacing: 4) {
|
||||
Text(text).lineLimit(1)
|
||||
Image(systemName: icon).font(.system(size: 9, weight: .heavy))
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.foregroundStyle(Look.bg)
|
||||
.background(Capsule().fill(.primary))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct Empty: View {
|
||||
let snap: WidgetSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: snap.hasTrack ? "slider.vertical.3" : "music.note")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(snap.hasTrack ? "No stems" : "Nothing playing")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(snap.hasTrack ? "Switch this song to Stems to mix it." : "Play a song in Stemify.")
|
||||
.font(.system(size: 11)).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mixer (medium, large) ----------------------------------------------------------------
|
||||
|
||||
struct MixerView: View {
|
||||
let entry: MixerEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snap: WidgetSnapshot { entry.snap }
|
||||
|
||||
var body: some View {
|
||||
if !snap.hasTrack {
|
||||
Empty(snap: snap)
|
||||
} else if family == .systemLarge {
|
||||
large
|
||||
} else {
|
||||
medium
|
||||
}
|
||||
}
|
||||
|
||||
private var medium: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Art(image: entry.art, size: 30)
|
||||
titles(13)
|
||||
Spacer(minLength: 4)
|
||||
if snap.hasStems { MixPill(text: snap.mix) }
|
||||
}
|
||||
if snap.hasStems {
|
||||
knobs(size: snap.stems.count > 4 ? 34 : 42, columns: snap.stems.count)
|
||||
} else {
|
||||
Empty(snap: snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var large: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
Art(image: entry.art, size: 56)
|
||||
titles(16)
|
||||
Spacer(minLength: 4)
|
||||
Button(intent: PlayPauseIntent()) {
|
||||
Image(systemName: snap.playing ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(Look.panel))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
if snap.hasStems {
|
||||
HStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary)
|
||||
Text(snap.mix).font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.lineLimit(1).minimumScaleFactor(0.6)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
MixPill(text: snap.next, icon: "forward.fill")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
knobs(size: 50, columns: snap.stems.count <= 4 ? snap.stems.count : 3)
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
Empty(snap: snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func titles(_ size: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(snap.title).font(.system(size: size, weight: .semibold)).lineLimit(1)
|
||||
Text(snap.artist).font(.system(size: size - 2)).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func knobs(size: CGFloat, columns: Int) -> some View {
|
||||
let grid = Array(repeating: GridItem(.flexible(), spacing: 6), count: max(columns, 1))
|
||||
return LazyVGrid(columns: grid, spacing: 12) {
|
||||
ForEach(snap.stems) { s in
|
||||
KnobCell(stem: s, silent: snap.silent(s), size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mix (small, lock screen) -------------------------------------------------------------
|
||||
|
||||
struct MixView: View {
|
||||
let entry: MixerEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snap: WidgetSnapshot { entry.snap }
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular: circular
|
||||
case .accessoryRectangular: rectangular
|
||||
default: small
|
||||
}
|
||||
}
|
||||
|
||||
private var small: some View {
|
||||
Group {
|
||||
if snap.hasStems {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
bars.frame(height: 34)
|
||||
Spacer(minLength: 6)
|
||||
Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary)
|
||||
Text(snap.mix).font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.lineLimit(2).minimumScaleFactor(0.6)
|
||||
Spacer(minLength: 6)
|
||||
MixPill(text: snap.next, icon: "forward.fill")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Empty(snap: snap)
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { Look.bg }
|
||||
}
|
||||
|
||||
/// One bar per stem, as tall as its fader.
|
||||
private var bars: some View {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
ForEach(snap.stems) { s in
|
||||
let silent = snap.silent(s)
|
||||
GeometryReader { g in
|
||||
VStack {
|
||||
Spacer(minLength: 0)
|
||||
Capsule()
|
||||
.fill(silent ? AnyShapeStyle(Look.well) : AnyShapeStyle(Color(hex: s.color)))
|
||||
.frame(height: max(4, g.size.height * min(max(s.level, 0), 1)))
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 9)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangular: some View {
|
||||
Button(intent: CycleMixIntent()) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "slider.vertical.3").font(.system(size: 18, weight: .semibold))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(snap.hasStems ? snap.mix : "Stemify").font(.headline).lineLimit(1).widgetAccentable()
|
||||
Text(snap.hasStems ? "Tap for \(snap.next)" : snap.hasTrack ? "No stems" : "Nothing playing")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
}
|
||||
|
||||
private var circular: some View {
|
||||
Button(intent: CycleMixIntent()) {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 1) {
|
||||
Image(systemName: "slider.vertical.3").font(.system(size: 15, weight: .semibold))
|
||||
if snap.hasStems {
|
||||
Text(snap.mix).font(.system(size: 9, weight: .semibold)).lineLimit(1).minimumScaleFactor(0.6)
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct StemifyWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MixerWidget()
|
||||
MixWidget()
|
||||
if #available(iOS 18.0, *) { MixControl() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio intents run in the app's process, so the extension's copy never does anything.
|
||||
@MainActor
|
||||
enum MixRemote {
|
||||
static func run(_ a: MixAction) {}
|
||||
}
|
||||
|
||||
// ---- timeline -----------------------------------------------------------------------------
|
||||
|
||||
struct MixerEntry: TimelineEntry {
|
||||
var date = Date()
|
||||
var snap: WidgetSnapshot
|
||||
var art: UIImage?
|
||||
|
||||
/// The app reloads the widgets whenever the song or mix changes, so one entry is enough.
|
||||
static func now() -> MixerEntry {
|
||||
let s = WidgetStore.load()
|
||||
let art = s.art == nil ? nil : WidgetStore.artURL.flatMap { UIImage(contentsOfFile: $0.path) }
|
||||
return MixerEntry(snap: s, art: art)
|
||||
}
|
||||
|
||||
static let sample = MixerEntry(snap: WidgetSnapshot(
|
||||
title: "Midnight City", artist: "M83", mix: "Karaoke", next: "A Cappella", playing: true,
|
||||
stems: [
|
||||
WidgetStem(name: "vocals", label: "Vocals", color: 0xFF7AB6, level: 0.8, percent: 100, muted: true),
|
||||
WidgetStem(name: "drums", label: "Drums", color: 0xFFB547, level: 0.8, percent: 100),
|
||||
WidgetStem(name: "bass", label: "Bass", color: 0x7C8CFF, level: 0.9, percent: 127),
|
||||
WidgetStem(name: "other", label: "Other", color: 0x4CD07D, level: 0.7, percent: 77),
|
||||
]))
|
||||
}
|
||||
|
||||
struct MixerProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> MixerEntry { .sample }
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (MixerEntry) -> Void) {
|
||||
let e = MixerEntry.now()
|
||||
completion(context.isPreview && !e.snap.hasStems ? .sample : e)
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<MixerEntry>) -> Void) {
|
||||
completion(Timeline(entries: [.now()], policy: .never))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- widgets ------------------------------------------------------------------------------
|
||||
|
||||
struct MixerWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: WidgetStore.mixerKind, provider: MixerProvider()) { e in
|
||||
MixerView(entry: e).containerBackground(for: .widget) { Look.bg }
|
||||
}
|
||||
.configurationDisplayName("Stem Mixer")
|
||||
.description("A knob for each stem. Tap the arrows to turn it, tap the knob to mute it.")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct MixWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: WidgetStore.mixKind, provider: MixerProvider()) { e in
|
||||
MixView(entry: e)
|
||||
}
|
||||
.configurationDisplayName("Mix")
|
||||
.description("The mix that's playing, and a button for the next one.")
|
||||
.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryCircular])
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the Lock Screen's flashlight or camera for a Mix button. Also works in Control Center and on the Action button.
|
||||
@available(iOS 18.0, *)
|
||||
struct MixControl: ControlWidget {
|
||||
var body: some ControlWidgetConfiguration {
|
||||
StaticControlConfiguration(kind: WidgetStore.controlKind, provider: Provider()) { mix in
|
||||
ControlWidgetButton(action: CycleMixIntent()) {
|
||||
Label(mix, systemImage: "slider.vertical.3")
|
||||
}
|
||||
}
|
||||
.displayName("Change Mix")
|
||||
.description("Moves to the next stem mix while stems play.")
|
||||
}
|
||||
|
||||
struct Provider: ControlValueProvider {
|
||||
var previewValue: String { "Karaoke" }
|
||||
func currentValue() async throws -> String {
|
||||
let s = WidgetStore.load()
|
||||
return s.hasStems ? s.mix : "Mix"
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-3
@@ -6,13 +6,15 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.17"
|
||||
CURRENT_PROJECT_VERSION: "18"
|
||||
MARKETING_VERSION: "1.1"
|
||||
CURRENT_PROJECT_VERSION: "19"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources: [Stemify]
|
||||
sources: [Stemify, Shared]
|
||||
dependencies:
|
||||
- target: StemifyWidgets
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify
|
||||
@@ -24,6 +26,7 @@ targets:
|
||||
properties:
|
||||
com.apple.developer.carplay-audio: true
|
||||
com.apple.developer.siri: true
|
||||
com.apple.security.application-groups: [group.org.cdnutter.stemify]
|
||||
info:
|
||||
path: Stemify/Info.plist
|
||||
properties:
|
||||
@@ -51,3 +54,24 @@ targets:
|
||||
CPTemplateApplicationSceneSessionRoleApplication:
|
||||
- UISceneConfigurationName: CarPlay
|
||||
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlayScene
|
||||
StemifyWidgets:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources: [Widgets, Shared]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify.widgets
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
CODE_SIGN_ENTITLEMENTS: Widgets/StemifyWidgets.entitlements
|
||||
entitlements:
|
||||
path: Widgets/StemifyWidgets.entitlements
|
||||
properties:
|
||||
com.apple.security.application-groups: [group.org.cdnutter.stemify]
|
||||
info:
|
||||
path: Widgets/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Stemify
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
+35
-33
@@ -1,44 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Builds build/stemify-<version>.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore.
|
||||
# Builds build/public/stemify-<version>.ipa on a Mac: ad-hoc signed, for releases and re-signing with Sideloadly/AltStore.
|
||||
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed
|
||||
# prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
|
||||
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
||||
# build/prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
|
||||
# Finished IPAs are kept per version; build/work is scratch and is wiped each run.
|
||||
# ios/scripts/build-remote.sh runs this on White-M1 and copies build/public and build/prov back to DevOps.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH=/opt/homebrew/bin:$PATH
|
||||
OUT=build; APP=$OUT/Payload/Stemify.app
|
||||
rm -rf $OUT && mkdir -p $APP
|
||||
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
|
||||
# Version and build number come from project.yml.
|
||||
B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex
|
||||
rm -rf $OUT && mkdir -p $OUT/Payload $B/public $B/prov
|
||||
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 \
|
||||
-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.
|
||||
# xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata.
|
||||
xcodegen generate -q
|
||||
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)/$VERSION/; s/\$(CURRENT_PROJECT_VERSION)/$BUILD/" \
|
||||
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
|
||||
P=$APP/Info.plist
|
||||
plutil -insert MinimumOSVersion -string 17.0 $P
|
||||
plutil -insert CFBundleSupportedPlatforms -json '["iPhoneOS"]' $P
|
||||
plutil -insert UIDeviceFamily -json '[1]' $P
|
||||
plutil -insert UIRequiredDeviceCapabilities -json '["arm64"]' $P
|
||||
plutil -insert DTPlatformName -string iphoneos $P
|
||||
plutil -insert CFBundleIcons -json '{"CFBundlePrimaryIcon":{"CFBundleIconFiles":["AppIcon60x60"]}}' $P
|
||||
plutil -lint $P
|
||||
printf 'APPL????' > $APP/PkgInfo
|
||||
xcodebuild -project Stemify.xcodeproj -scheme Stemify -configuration Release -sdk iphoneos \
|
||||
-destination generic/platform=iOS -derivedDataPath $B/dd CODE_SIGNING_ALLOWED=NO -quiet build
|
||||
cp -R $B/dd/Build/Products/Release-iphoneos/Stemify.app $APP
|
||||
[ -d $EXT ] || { echo "widget extension missing" >&2; exit 1; }
|
||||
|
||||
# Loose icon files, since actool also needs -runFirstLaunch.
|
||||
SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
|
||||
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
||||
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
||||
# Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing.
|
||||
cat > $OUT/group.plist <<PL
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>com.apple.security.application-groups</key><array><string>group.org.cdnutter.stemify</string></array></dict></plist>
|
||||
PL
|
||||
codesign -f -s - --entitlements $OUT/group.plist $EXT
|
||||
codesign -f -s - --entitlements $OUT/group.plist $APP
|
||||
|
||||
# Unsigned build, for the releases and AltStore/Sideloadly.
|
||||
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
||||
echo "$OUT/stemify-$VERSION.ipa"
|
||||
# Public build, for the releases and AltStore/Sideloadly.
|
||||
rm -f $B/public/stemify-$VERSION.ipa
|
||||
(cd $OUT && zip -qry ../public/stemify-$VERSION.ipa Payload)
|
||||
echo "$B/public/stemify-$VERSION.ipa"
|
||||
|
||||
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
|
||||
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
|
||||
@@ -54,12 +46,22 @@ if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PAS
|
||||
ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1)
|
||||
security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist
|
||||
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist
|
||||
# The widgets need their own profile (org.cdnutter.stemify.widgets with the App Group). Without one they're left out.
|
||||
if [ -f prov/StemifyWidgets.mobileprovision ]; then
|
||||
security cms -D -i prov/StemifyWidgets.mobileprovision > $OUT/wprov.plist
|
||||
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/wprov.plist > $OUT/went.plist
|
||||
cp prov/StemifyWidgets.mobileprovision $EXT/embedded.mobileprovision
|
||||
codesign -f -s "$ID" --entitlements $OUT/went.plist --timestamp=none $EXT
|
||||
else
|
||||
rm -rf $APP/PlugIns
|
||||
fi
|
||||
cp prov/Stemify.mobileprovision $APP/embedded.mobileprovision
|
||||
codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP
|
||||
security list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain)
|
||||
security delete-keychain $KC
|
||||
# Personal build for this device only: stays in prov/ (gitignored), never released.
|
||||
# Personal build for this device only: stays in build/prov (gitignored), never released.
|
||||
rm -f $B/prov/stemify-$VERSION-signed.ipa
|
||||
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
|
||||
echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)"
|
||||
echo "$B/prov/stemify-$VERSION-signed.ipa (signed as $ID)"
|
||||
fi
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Run from DevOps: syncs ios/ to White-M1, builds there, and pulls the IPAs back into ios/build/{public,prov}.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
H=${BUILD_HOST:-white}; R=stemify-build/ios
|
||||
rsync -a --exclude build --exclude prov --exclude Stemify.xcodeproj ./ $H:$R/
|
||||
ssh $H "cd $R && P12_PASS=\$(cat prov/cert_pass.txt 2>/dev/null) bash scripts/build-ipa.sh"
|
||||
mkdir -p build/public build/prov
|
||||
rsync -a $H:$R/build/public/ build/public/
|
||||
rsync -a $H:$R/build/prov/ build/prov/
|
||||
ls build/public build/prov
|
||||
Reference in new issue
Block a user