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:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+11 -1
View File
@@ -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)
}
}
+4 -1
View File
@@ -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() }
}
}
}
+90
View File
@@ -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)
}
}
}
+87
View File
@@ -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
+9 -2
View File
@@ -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)
+1 -1
View File
@@ -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() }
+7 -6
View File
@@ -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 {
+52 -3
View File
@@ -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 }
+107 -14
View File
@@ -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([])
}
}
+24 -10
View File
@@ -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))
+60 -20
View File
@@ -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()
+1 -1
View File
@@ -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")
+80
View File
@@ -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
}
}
+448
View File
@@ -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: ""))
}
}
+116
View File
@@ -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
View File
@@ -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) }
}
+347
View File
@@ -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}}
+4 -4
View File
@@ -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)] = [:]
+5 -5
View File
@@ -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)
+43 -41
View File
@@ -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) })
+8 -5
View File
@@ -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() }
}
}
+2 -2
View File
@@ -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)
}
+7 -2
View File
@@ -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)
+188 -111
View File
@@ -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()
}
}
+118 -27
View File
@@ -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())
}
}
}
+1 -1
View File
@@ -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) }
+429 -148
View File
@@ -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))
+163
View File
@@ -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 }
}
}
+116
View File
@@ -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 }
}
}