Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
|
||||
let MARGIN: CGFloat = 20
|
||||
|
||||
func formatDuration(_ ms: Int64) -> String {
|
||||
let s = max(ms / 1000, 0)
|
||||
return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s / 60 % 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
|
||||
func formatMinutes(_ ms: Int64) -> String {
|
||||
let m = Int(ms / 60000)
|
||||
return m >= 60 ? "\(m / 60) hr \(m % 60) min" : "\(m) minute\(m == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
func songs(_ n: Int) -> String { "\(n) song\(n == 1 ? "" : "s")" }
|
||||
|
||||
func gb(_ bytes: Int64) -> String {
|
||||
let b = Double(bytes)
|
||||
return b < 1e9 ? String(format: "%.0f MB", b / 1e6) : String(format: "%.1f GB", b / 1e9)
|
||||
}
|
||||
|
||||
/// Plex artwork through the photo transcoder, sized to the screen's pixels.
|
||||
struct Artwork: View {
|
||||
let thumb: String?
|
||||
let size: CGFloat
|
||||
var corner: CGFloat = 6
|
||||
var circle = false
|
||||
var placeholder = "music.note"
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
@State private var image: UIImage?
|
||||
|
||||
private var url: URL? {
|
||||
// Round the request up so nearby sizes share cache entries.
|
||||
let px = min(max(Int((size * scale / 120).rounded(.up)) * 120, 120), 1440)
|
||||
return AppGraph.shared.plex.artUrl(thumb, px: px)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let shape = circle ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||
ZStack {
|
||||
p.placeholder
|
||||
Image(systemName: placeholder).font(.system(size: size * 0.38)).foregroundStyle(p.label3)
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.stroke(p.separator.opacity(0.6), lineWidth: 0.5))
|
||||
.task(id: url) {
|
||||
guard let url else { image = nil; return }
|
||||
if let c = ArtLoader.shared.cached(url) { image = c; return }
|
||||
image = nil
|
||||
image = await ArtLoader.shared.image(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings.app-style coloured rounded-square icon.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let bg: UInt32
|
||||
var body: some View {
|
||||
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon, title and grey value, as in Settings.app.
|
||||
struct ValueCell: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var icon: String?
|
||||
var iconBg: UInt32 = 0x8E8E93
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
if let icon { SettingsIcon(symbol: icon, bg: iconBg) }
|
||||
Text(title).foregroundStyle(p.label)
|
||||
Spacer(minLength: 8)
|
||||
if let value { Text(value).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyState: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: icon).font(.system(size: 52)).foregroundStyle(p.label3)
|
||||
Text(title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).padding(.top, 14)
|
||||
Text(message).font(.subheadline).foregroundStyle(p.label2).multilineTextAlignment(.center).padding(.top, 6)
|
||||
if let action {
|
||||
Button(action: onAction) {
|
||||
Text(action).font(.headline).foregroundStyle(.white).padding(.horizontal, 20).padding(.vertical, 10)
|
||||
.background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40).padding(.vertical, 70)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingRow: View {
|
||||
var body: some View { ProgressView().controlSize(.large).frame(maxWidth: .infinity).padding(.vertical, 40) }
|
||||
}
|
||||
|
||||
struct ErrorRow: View {
|
||||
let message: String
|
||||
let retry: () -> Void
|
||||
@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)
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PillButton: View {
|
||||
let text: String
|
||||
let icon: String
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon).font(.system(size: 17, weight: .semibold))
|
||||
Text(text).font(.headline)
|
||||
}
|
||||
.foregroundStyle(p.accent)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Play / Shuffle pair under a header.
|
||||
struct PlayShuffle: View {
|
||||
let play: () -> Void
|
||||
let shuffle: () -> Void
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
PillButton(text: "Play", icon: "play.fill", action: play)
|
||||
PillButton(text: "Shuffle", icon: "shuffle", action: shuffle)
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
/// Capsule chip, accent-filled when selected.
|
||||
struct Chip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? .white : p.label)
|
||||
.padding(.horizontal, 14).frame(height: 32)
|
||||
.background(selected ? p.accent : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Small outlined tag, e.g. "NO STEMS" next to a track.
|
||||
struct Tag: View {
|
||||
let text: String
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
let c = color ?? p.label2
|
||||
Text(text).font(.system(size: 10, weight: .bold)).foregroundStyle(c)
|
||||
.padding(.horizontal, 4).padding(.vertical, 1)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(c, lineWidth: 1))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloaded marker: filled circle with a down arrow, as in Apple Music's lists.
|
||||
struct DownloadedMark: View {
|
||||
var color: Color?
|
||||
var size: CGFloat = 14
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Image(systemName: "arrow.down.circle.fill").font(.system(size: size)).foregroundStyle(color ?? p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download progress ring.
|
||||
struct ProgressRing: View {
|
||||
let progress: Float
|
||||
var size: CGFloat = 18
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(color.map { $0.opacity(0.3) } ?? p.fillStrong, lineWidth: 2)
|
||||
Circle().trim(from: 0, to: CGFloat(min(max(progress, 0), 1)))
|
||||
.stroke(color ?? p.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: size - 2, height: size - 2)
|
||||
.animation(.linear(duration: 0.2), value: progress)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated "now playing" bars used in place of a track number.
|
||||
struct PlayingBars: View {
|
||||
let playing: Bool
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: !playing)) { tl in
|
||||
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
|
||||
Canvas { ctx, size in
|
||||
let w = size.width / 4.5
|
||||
for i in 0..<3 {
|
||||
let h = playing ? 0.35 + 0.65 * abs(sin(phase + Double(i) * 1.9)) : 0.3 + Double(i) * 0.15
|
||||
let bh = size.height * h
|
||||
let r = CGRect(x: Double(i) * w * 1.6 + w * 0.3, y: size.height - bh, width: w, height: bh)
|
||||
ctx.fill(Path(roundedRect: r, cornerRadius: w / 3), with: .color(color ?? p.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Section title with an optional accent action, as in Apple Music ("Recently Added").
|
||||
struct SectionTitle: View {
|
||||
let title: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title).font(.title2.bold()).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
if let action { Button(action, action: onAction).foregroundStyle(p.accent) }
|
||||
}
|
||||
.padding(.leading, MARGIN).padding(.trailing, 12).padding(.top, 22).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a screen's data is doing. A failed refresh keeps the data already on screen.
|
||||
enum LoadState<T> {
|
||||
case loading
|
||||
case ok(T)
|
||||
case err(String)
|
||||
|
||||
var value: T? { if case .ok(let v) = self { v } else { nil } }
|
||||
}
|
||||
|
||||
extension LoadState {
|
||||
/// Runs [f], keeping old data if it fails after a success.
|
||||
@MainActor
|
||||
static func load(_ state: Binding<LoadState<T>>, _ f: () async throws -> T) async {
|
||||
do {
|
||||
state.wrappedValue = .ok(try await f())
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
if state.wrappedValue.value == nil { state.wrappedValue = .err(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The loading spinner, error page or content for a [LoadState].
|
||||
struct Loaded<T, Content: View>: View {
|
||||
let state: LoadState<T>
|
||||
let retry: () -> Void
|
||||
@ViewBuilder let content: (T) -> Content
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .loading: LoadingRow()
|
||||
case .err(let m): ErrorRow(message: m, retry: retry)
|
||||
case .ok(let v): content(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain scrolling page with a large title, like the Android shell's Page.
|
||||
struct Page<Content: View>: View {
|
||||
let title: String
|
||||
var large = true
|
||||
@ViewBuilder let content: () -> Content
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) { content() }
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(p.bg)
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(large ? .large : .inline)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Row padding plus the inset hairline under it.
|
||||
func listRow(inset: CGFloat = MARGIN, leading: CGFloat = MARGIN, trailing: CGFloat = 6, line: Bool = true) -> some View {
|
||||
modifier(ListRow(inset: inset, leading: leading, trailing: trailing, line: line))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ListRow: ViewModifier {
|
||||
let inset: CGFloat, leading: CGFloat, trailing: CGFloat, line: Bool
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
func body(content: Content) -> some View {
|
||||
content.padding(.leading, leading).padding(.trailing, trailing)
|
||||
.overlay(alignment: .bottom) {
|
||||
if line { p.separator.frame(height: 1 / scale).padding(.leading, inset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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).
|
||||
/// Each call returns the actions; follow-up choices go through [Overlays].
|
||||
@MainActor
|
||||
struct MediaMenu {
|
||||
let overlays: Overlays
|
||||
let ui: Ui
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
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 {
|
||||
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]) })
|
||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||
if allowGoTo, let k = t.albumKey {
|
||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||
ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)))
|
||||
})
|
||||
}
|
||||
if allowGoTo, let k = t.artistKey {
|
||||
a.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: t.artist, sortTitle: t.artist))) })
|
||||
}
|
||||
return a + extra
|
||||
}
|
||||
|
||||
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) },
|
||||
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) },
|
||||
playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let plex = g.plex
|
||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
||||
confirmDownload: pl.trackCount > 50)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// True when every song is known to have stems, so there's nothing for Stem Now to do.
|
||||
private func stemmed(_ cov: Coverage?) -> Bool {
|
||||
guard g.coverage.ready, let cov else { return false }
|
||||
return cov.withStems >= cov.total
|
||||
}
|
||||
|
||||
private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ 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) }
|
||||
})
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
||||
if !stemmed { a.append(stemNow(load)) }
|
||||
return a + extra
|
||||
}
|
||||
|
||||
/// Front of the queue (right after this song) and 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") {
|
||||
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") {
|
||||
withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 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") {
|
||||
Task {
|
||||
do {
|
||||
let n = try await g.stems.stemNow(try await load())
|
||||
if n == 0 { overlays.hud("Already has stems", "info.circle") }
|
||||
else { overlays.hud(n == 1 ? "Stemming next" : "Stemming \(songs(n)) next", "waveform") }
|
||||
} catch {
|
||||
overlays.hud("Couldn't queue: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withTracks(_ load: @escaping () async throws -> [Track], _ then: @escaping ([Track]) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let t = try await load()
|
||||
if t.isEmpty { overlays.hud("Nothing to add", "info.circle") } else { then(t) }
|
||||
} catch {
|
||||
overlays.hud("Couldn't load songs: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
||||
Task {
|
||||
let lists: [Playlist]
|
||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
return
|
||||
}
|
||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
||||
if keys.isEmpty {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
||||
return
|
||||
}
|
||||
let plex = g.plex
|
||||
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in
|
||||
SheetAction(pl.title) {
|
||||
Task {
|
||||
do {
|
||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
overlays.hud("Added to \(pl.title)")
|
||||
} catch {
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||
let plex = g.plex
|
||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
||||
Task {
|
||||
do {
|
||||
_ = try await plex.createPlaylist(name, trackKeys: tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") })
|
||||
overlays.hud("Created “\(name)”")
|
||||
onCreated()
|
||||
} catch {
|
||||
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Ui {
|
||||
var menu: MediaMenu { MediaMenu(overlays: overlays, ui: self) }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SheetAction: Identifiable {
|
||||
let id = UUID()
|
||||
var title: String
|
||||
var icon: String?
|
||||
var destructive = false
|
||||
var run: () -> Void
|
||||
|
||||
init(_ title: String, icon: String? = nil, destructive: Bool = false, _ run: @escaping () -> Void) {
|
||||
self.title = title; self.icon = icon; self.destructive = destructive; self.run = run
|
||||
}
|
||||
}
|
||||
|
||||
/// Action sheets, alerts, text prompts and the HUD, shown from anywhere. They're drawn by
|
||||
/// [OverlayHost]: one under the tabs and one inside the Now Playing sheet, and only the
|
||||
/// topmost of those shows them.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Overlays {
|
||||
struct Actions { var title: String; var items: [SheetAction] }
|
||||
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
||||
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
||||
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
var level = 0
|
||||
|
||||
private var hudTask: Task<Void, Never>?
|
||||
|
||||
private var showing: Bool { actions != nil || confirm != nil || prompt != nil }
|
||||
|
||||
/// Presents after the current dialog has gone, since SwiftUI drops one shown on top of another.
|
||||
private func present(_ f: @escaping () -> Void) {
|
||||
if showing {
|
||||
actions = nil; confirm = nil; prompt = nil
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
f()
|
||||
}
|
||||
} else {
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
func actions(_ title: String, _ items: [SheetAction]) {
|
||||
present { self.actions = Actions(title: title, items: items) }
|
||||
}
|
||||
|
||||
func confirm(_ title: String, _ message: String?, _ button: String, destructive: Bool = true, _ run: @escaping () -> Void) {
|
||||
present { self.confirm = Confirm(title: title, message: message, button: button, destructive: destructive, run: run) }
|
||||
}
|
||||
|
||||
func prompt(_ title: String, _ message: String?, initial: String = "", _ button: String, placeholder: String = "", _ run: @escaping (String) -> Void) {
|
||||
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
||||
}
|
||||
|
||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
let secs = text.count > 40 ? 2.8 : 1.4
|
||||
hudTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(secs * 1e9))
|
||||
if Task.isCancelled { return }
|
||||
withAnimation(.easeIn(duration: 0.25)) { self.hud = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if mine, let h = overlays.hud {
|
||||
HudView(hud: h).transition(.opacity.combined(with: .scale(scale: 0.9))).allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(overlays.actions?.title ?? "", isPresented: binding(\.actions), titleVisibility: .visible, presenting: overlays.actions) { a in
|
||||
ForEach(a.items) { item in
|
||||
Button(item.title, role: item.destructive ? .destructive : nil) { item.run() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert(overlays.confirm?.title ?? "", isPresented: binding(\.confirm), presenting: overlays.confirm) { c in
|
||||
Button(c.button, role: c.destructive ? .destructive : nil) { c.run() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { c in
|
||||
if let m = c.message { Text(m) }
|
||||
}
|
||||
.alert(overlays.prompt?.title ?? "", isPresented: binding(\.prompt), presenting: overlays.prompt) { p in
|
||||
TextField(p.placeholder, text: $text).autocorrectionDisabled()
|
||||
Button(p.button) {
|
||||
let v = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !v.isEmpty { p.run(v) }
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { p in
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||
}
|
||||
|
||||
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
||||
Binding(get: { mine && overlays[keyPath: kp] != nil }, set: { if !$0 { overlays[keyPath: kp] = nil } })
|
||||
}
|
||||
}
|
||||
|
||||
private struct HudView: View {
|
||||
let hud: Overlays.Hud
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: hud.icon).font(.system(size: 40, weight: .medium))
|
||||
Text(hud.text).font(.subheadline.weight(.semibold)).multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 150, minHeight: 150)
|
||||
.frame(maxWidth: 240)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func overlayHost(_ o: Overlays, level: Int) -> some View { modifier(OverlayHost(overlays: o, level: level)) }
|
||||
|
||||
/// The same actions on long-press as the ••• menu.
|
||||
func actionsMenu(_ items: [SheetAction]) -> some View {
|
||||
contextMenu { ActionButtons(items: items) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ActionButtons: View {
|
||||
let items: [SheetAction]
|
||||
var body: some View {
|
||||
ForEach(items) { item in
|
||||
Button(role: item.destructive ? .destructive : nil) { item.run() } label: {
|
||||
if let i = item.icon { Label(item.title, systemImage: i) } else { Text(item.title) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ••• button, as a native menu.
|
||||
struct MoreMenu: View {
|
||||
let items: [SheetAction]
|
||||
var size: CGFloat = 17
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Menu { ActionButtons(items: items) } label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: size, weight: .semibold))
|
||||
.frame(width: 34, height: 34).contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "NO STEMS" / "3/10 STEMS" for an album or artist, once the whole library has been matched.
|
||||
struct StemFlag: View {
|
||||
let cov: Coverage?
|
||||
var body: some View {
|
||||
if AppGraph.shared.coverage.ready, let cov {
|
||||
if cov.none { Tag(text: "NO STEMS") } else if cov.partial { Tag(text: "\(cov.withStems)/\(cov.total) STEMS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple Music list row: art (or track number), title, artist, badges, and a ••• menu.
|
||||
/// Stems are the norm once the server has worked through the library, so the badge marks the
|
||||
/// songs without them, and only once the index has loaded.
|
||||
struct TrackRow<Leading: View>: View {
|
||||
let track: Track
|
||||
var number: Int?
|
||||
var showArt = true
|
||||
var subtitle: String?
|
||||
var menu: [SheetAction]?
|
||||
let action: () -> Void
|
||||
@ViewBuilder var leading: () -> Leading
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
let g = AppGraph.shared
|
||||
let pm = g.playback
|
||||
let isCurrent = pm.current?.track.ratingKey == track.ratingKey
|
||||
let noStems = g.stems.index.size > 0 && g.stems.find(track) == nil
|
||||
let downloaded = g.downloads.records[track.ratingKey]?.done == true
|
||||
let progress = g.downloads.progress[track.ratingKey]
|
||||
HStack(spacing: 0) {
|
||||
leading()
|
||||
if showArt {
|
||||
ZStack {
|
||||
Artwork(thumb: track.thumb, size: 48, corner: 5)
|
||||
if isCurrent {
|
||||
Color.black.opacity(0.35).frame(width: 48, height: 48).clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
PlayingBars(playing: pm.playWhenReady, color: .white)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
} else if let number {
|
||||
Group {
|
||||
if isCurrent { PlayingBars(playing: pm.playWhenReady) } else { Text("\(number)").foregroundStyle(p.label2) }
|
||||
}
|
||||
.frame(width: 32, alignment: .leading)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).foregroundStyle(isCurrent ? p.accent : p.label).lineLimit(1)
|
||||
if let s = subtitle ?? (showArt ? track.artist : nil) {
|
||||
Text(s).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
Spacer(minLength: 6)
|
||||
if noStems { Tag(text: "NO STEMS").padding(.trailing, 6) }
|
||||
if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) }
|
||||
else if downloaded { DownloadedMark().padding(.trailing, 4) }
|
||||
if let menu { MoreMenu(items: menu).foregroundStyle(p.label) }
|
||||
}
|
||||
.frame(minHeight: showArt ? 56 : 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.modifier(OptionalMenu(items: menu))
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackRow where Leading == EmptyView {
|
||||
init(track: Track, number: Int? = nil, showArt: Bool = true, subtitle: String? = nil, menu: [SheetAction]? = nil, action: @escaping () -> Void) {
|
||||
self.init(track: track, number: number, showArt: showArt, subtitle: subtitle, menu: menu, action: action) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionalMenu: ViewModifier {
|
||||
let items: [SheetAction]?
|
||||
func body(content: Content) -> some View {
|
||||
if let items { content.actionsMenu(items) } else { content }
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-column album grid. [subtitle] replaces the artist (or year) line; [badge] sits on the art's corner.
|
||||
struct AlbumGrid<Badge: View>: View {
|
||||
let albums: [Album]
|
||||
var showArtist = true
|
||||
var subtitle: ((Album) -> String?)?
|
||||
var menu: ((Album) -> [SheetAction])?
|
||||
let open: (Album) -> Void
|
||||
@ViewBuilder var badge: (Album) -> Badge
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
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) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
Artwork(thumb: a.thumb, size: geo.size.width, corner: 8, placeholder: "square.stack")
|
||||
.overlay(alignment: .bottomTrailing) { badge(a).padding(8) }
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
HStack(spacing: 6) {
|
||||
Text(a.title).font(.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) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { open(a) }
|
||||
.modifier(OptionalMenu(items: menu?(a)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistRow: View {
|
||||
let artist: Artist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: artist.thumb, size: 44, circle: true, placeholder: "person.fill")
|
||||
Text(artist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Spacer()
|
||||
StemFlag(cov: AppGraph.shared.coverage.artists[artist.ratingKey])
|
||||
}
|
||||
.frame(minHeight: 52)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Tab: String, CaseIterable, Hashable {
|
||||
case Library, Playlists, Search, Downloads, Settings
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .Library: "square.stack.fill"
|
||||
case .Playlists: "music.note.list"
|
||||
case .Search: "magnifyingglass"
|
||||
case .Downloads: "arrow.down.circle.fill"
|
||||
case .Settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist)
|
||||
}
|
||||
|
||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Ui {
|
||||
var tab: Tab = .Library
|
||||
var paths: [Tab: [Route]] = [:]
|
||||
var nowPlayingOpen = false {
|
||||
didSet { overlays.level = nowPlayingOpen ? 1 : 0 }
|
||||
}
|
||||
let overlays: Overlays
|
||||
|
||||
init(overlays: Overlays) { self.overlays = overlays }
|
||||
|
||||
func path(_ t: Tab) -> Binding<[Route]> {
|
||||
Binding(get: { self.paths[t] ?? [] }, set: { self.paths[t] = $0 })
|
||||
}
|
||||
|
||||
func push(_ r: Route) { paths[tab, default: []].append(r) }
|
||||
|
||||
func pop() { if paths[tab]?.isEmpty == false { paths[tab]?.removeLast() } }
|
||||
|
||||
/// Tapping the selected tab again pops it to its root.
|
||||
func selectTab(_ t: Tab) {
|
||||
if t == tab { paths[t] = [] } else { tab = t }
|
||||
}
|
||||
|
||||
func openNowPlaying() { nowPlayingOpen = true }
|
||||
|
||||
/// Switches to the Library tab and pushes [r] there.
|
||||
func showInLibrary(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
tab = .Library
|
||||
paths[.Library, default: []].append(r)
|
||||
}
|
||||
|
||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||
func showInSettings(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
tab = .Settings
|
||||
if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
|
||||
struct RootView: View {
|
||||
@State private var ui = Ui(overlays: Overlays())
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let p = Palette.of(s.theme, s.accent)
|
||||
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
||||
ForEach(Tab.allCases, id: \.self) { t in
|
||||
NavigationStack(path: ui.path(t)) {
|
||||
root(t).navigationDestination(for: Route.self) { destination($0) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if g.playback.current != nil { MiniPlayer() }
|
||||
}
|
||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||
.tag(t)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
.environment(ui)
|
||||
.environment(\.palette, p)
|
||||
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
||||
.tint(p.accent)
|
||||
}
|
||||
.overlayHost(ui.overlays, level: 0)
|
||||
.onChange(of: g.playback.message) { _, m in
|
||||
if let m { ui.overlays.hud(m.text, "info.circle") }
|
||||
}
|
||||
.environment(ui)
|
||||
.environment(\.palette, p)
|
||||
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
||||
.tint(p.accent)
|
||||
}
|
||||
|
||||
@ViewBuilder private func root(_ t: Tab) -> some View {
|
||||
switch t {
|
||||
case .Library: LibraryHome()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Search: SearchScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
case .Settings: SettingsScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(_ r: Route) -> some View {
|
||||
switch r {
|
||||
case .Artists: ArtistsScreen()
|
||||
case .Albums: AlbumsScreen()
|
||||
case .Songs: SongsScreen()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
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 .StemServer: StemServerScreen()
|
||||
case .Quality: QualityScreen()
|
||||
case .Storage: StorageScreen()
|
||||
case .Appearance: AppearanceScreen()
|
||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// ---- formatting and slider mappings ---------------------------------------------------------
|
||||
|
||||
private let F_LO = 20.0
|
||||
private let F_HI = 20000.0
|
||||
private let RIGHT = Color(hex: 0xFF9500)
|
||||
|
||||
func hz(_ f: Float) -> String {
|
||||
guard f >= 1000 else { return "\(Int(f)) Hz" }
|
||||
return String(format: f >= 10000 ? "%.1f kHz" : "%.2f kHz", f / 1000)
|
||||
.replacingOccurrences(of: ".00 ", with: " ").replacingOccurrences(of: ".0 ", with: " ")
|
||||
}
|
||||
|
||||
private func db(_ v: Float) -> String {
|
||||
v > 0.05 ? String(format: "+%.1f", v) : v < -0.05 ? String(format: "−%.1f", -v) : "0.0"
|
||||
}
|
||||
|
||||
private func signedDb(_ v: Float) -> String {
|
||||
(v > 0.005 ? "+" : v < -0.005 ? "−" : "") + String(format: "%.1f dB", abs(v))
|
||||
}
|
||||
|
||||
private func freqToPos(_ f: Float) -> Float { Float(min(max(log(Double(f) / F_LO) / log(F_HI / F_LO), 0), 1)) }
|
||||
private func posToFreq(_ p: Float) -> Float { Float(F_LO * pow(F_HI / F_LO, Double(p))) }
|
||||
private func qToPos(_ q: Float) -> Float {
|
||||
min(max(log(q / EqController.qMin) / log(EqController.qMax / EqController.qMin), 0), 1)
|
||||
}
|
||||
private func posToQ(_ p: Float) -> Float { EqController.qMin * pow(EqController.qMax / EqController.qMin, p) }
|
||||
|
||||
/// Rounds a dragged frequency to what a person would type: 3 significant figures.
|
||||
private func tidyFreq(_ f: Float) -> Float {
|
||||
let mag = Float(pow(10.0, floor(log10(Double(f))) - 2))
|
||||
return (f / mag).rounded() * mag
|
||||
}
|
||||
|
||||
private func round1(_ v: Float) -> Float { (v * 10).rounded() / 10 }
|
||||
|
||||
private func parseNumber(_ s: String) -> Float? {
|
||||
Float(s.replacingOccurrences(of: ",", with: ".").replacingOccurrences(of: "−", with: "-")
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "-" })
|
||||
}
|
||||
|
||||
private func trimmed(_ v: Float) -> String {
|
||||
var s = String(format: "%.2f", v)
|
||||
while s.hasSuffix("0") { s.removeLast() }
|
||||
if s.hasSuffix(".") { s.removeLast() }
|
||||
return s
|
||||
}
|
||||
|
||||
private func summary(_ f: EqFilter) -> String {
|
||||
var parts: [String] = []
|
||||
if f.type.hasGain { parts.append(signedDb(f.gain)) }
|
||||
if f.type.hasSlope { parts.append("\(f.slope) dB/oct") }
|
||||
if !f.type.hasSlope || f.slope == 12 { parts.append(String(format: "Q %.2f", f.q)) }
|
||||
if f.channel != .Both { parts.append(f.channel.label) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Sensible starting points, audible but mild, for a new filter of each type.
|
||||
private func newFilter(_ type: FilterType) -> EqFilter {
|
||||
var f = EqFilter(id: EqController.newFilterId(), type: type)
|
||||
switch type {
|
||||
case .Peak: f.freq = 1000; f.gain = 3; f.q = 1.41
|
||||
case .LowShelf: f.freq = 105; f.gain = 3; f.q = 0.71
|
||||
case .HighShelf: f.freq = 8000; f.gain = 3; f.q = 0.71
|
||||
case .LowPass: f.freq = 16000; f.q = 0.71
|
||||
case .HighPass: f.freq = 30; f.q = 0.71; f.slope = 24
|
||||
case .BandPass: f.freq = 1000; f.q = 0.71
|
||||
case .Notch: f.freq = 60; f.q = 4
|
||||
case .AllPass: f.freq = 1000; f.q = 0.71
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func effectivePreamp(_ p: EqProfile) -> Float {
|
||||
p.preamp + (AppGraph.shared.eq.state.antiClip ? EqMath.autoPreampDb(p.chain, manualDb: p.preamp) : 0)
|
||||
}
|
||||
|
||||
// ---- response curve -------------------------------------------------------------------------
|
||||
|
||||
/// The EQ's response, log frequency across, dB up. Left and right are drawn separately when
|
||||
/// some filter applies to only one of them (right in orange). [nodes] are drawn as numbered
|
||||
/// handles; [highlight] gets its own curve in the accent colour.
|
||||
struct EqCurve: View {
|
||||
let chain: [EqFilter]
|
||||
let preampDb: Float
|
||||
let enabled: Bool
|
||||
var range: Float = 18
|
||||
var bandMarkers = false
|
||||
var nodes: [EqFilter] = []
|
||||
var highlight: EqFilter?
|
||||
var labels = false
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
private static let freqs = EqMath.logFreqs(200, lo: F_LO, hi: F_HI)
|
||||
|
||||
var body: some View {
|
||||
let left = EqMath.response(chain, Self.freqs, left: true)
|
||||
let right = chain.contains { $0.channel != .Both } ? EqMath.response(chain, Self.freqs, left: false) : nil
|
||||
let own = highlight.map { EqMath.response([$0], Self.freqs) }
|
||||
Canvas { ctx, size in
|
||||
let r = Double(range)
|
||||
func y(_ d: Double) -> CGFloat { size.height / 2 - CGFloat(d / r) * size.height / 2 }
|
||||
func x(_ f: Double) -> CGFloat { CGFloat(log(f / F_LO) / log(F_HI / F_LO)) * size.width }
|
||||
func hline(_ yy: CGFloat, _ c: Color, _ w: CGFloat) {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: 0, y: yy)); $0.addLine(to: CGPoint(x: size.width, y: yy)) }, with: .color(c), lineWidth: w)
|
||||
}
|
||||
var d = 6.0
|
||||
while d < r { hline(y(d), p.separator, 1); hline(y(-d), p.separator, 1); d += 6 }
|
||||
hline(y(0), p.label3, 1.5)
|
||||
let grid = bandMarkers ? EQ_BANDS.map(Double.init) : [50, 100, 200, 500, 1000, 2000, 5000, 10000]
|
||||
for f in grid {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x(f), y: 0)); $0.addLine(to: CGPoint(x: x(f), y: size.height)) }, with: .color(p.separator), lineWidth: 1)
|
||||
}
|
||||
if labels {
|
||||
for (f, l) in [(100.0, "100"), (1000.0, "1k"), (10000.0, "10k")] {
|
||||
ctx.draw(Text(l).font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: x(f) + 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
ctx.draw(Text("+\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: 1), anchor: .topLeading)
|
||||
ctx.draw(Text("−\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
func curve(_ v: [Double]) -> Path {
|
||||
Path { path in
|
||||
for (i, d) in v.enumerated() {
|
||||
let pt = CGPoint(x: CGFloat(i) / CGFloat(v.count - 1) * size.width, y: y(d))
|
||||
if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) }
|
||||
}
|
||||
}
|
||||
}
|
||||
let round = StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
|
||||
let main = enabled ? p.accent : p.label3
|
||||
if let own {
|
||||
let c = enabled ? main.opacity(0.35) : p.label3
|
||||
var area = curve(own)
|
||||
area.addLine(to: CGPoint(x: size.width, y: y(0)))
|
||||
area.addLine(to: CGPoint(x: 0, y: y(0)))
|
||||
area.closeSubpath()
|
||||
ctx.fill(area, with: .color(c.opacity(0.4)))
|
||||
ctx.stroke(curve(own), with: .color(c), style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
ctx.stroke(curve(left), with: .color(highlight != nil ? p.label : main), style: round)
|
||||
if let right {
|
||||
ctx.stroke(curve(right), with: .color(enabled ? RIGHT : p.label3), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
if preampDb < 0 && highlight == nil {
|
||||
ctx.stroke(curve(left.map { $0 + Double(preampDb) }), with: .color(p.label2),
|
||||
style: StrokeStyle(lineWidth: 1.2, lineCap: .round, dash: [6, 6]))
|
||||
}
|
||||
for (i, f) in nodes.enumerated() {
|
||||
let sel = f.id == highlight?.id
|
||||
let cx = x(min(max(Double(f.freq), F_LO), F_HI))
|
||||
let cy = f.type.hasGain ? y(min(max(Double(f.gain), -r), r)) : y(0)
|
||||
let rad: CGFloat = sel ? 11 : 9
|
||||
let col = !f.on ? p.label3 : f.channel == .Right ? RIGHT : main
|
||||
let dot = Path(ellipseIn: CGRect(x: cx - rad, y: cy - rad, width: rad * 2, height: rad * 2))
|
||||
ctx.fill(dot, with: .color(sel ? .white : col))
|
||||
if sel { ctx.stroke(dot, with: .color(col), lineWidth: 2.5) }
|
||||
ctx.draw(Text("\(i + 1)").font(.caption2.bold()).foregroundStyle(sel ? col : .white), at: CGPoint(x: cx, y: cy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One graphic EQ band: a vertical track with a thumb, −1...1, snapping to 0 near the middle.
|
||||
struct VerticalBandSlider: View {
|
||||
let value: Float
|
||||
let onChange: (Float) -> Void
|
||||
@Environment(\.palette) private var p
|
||||
@State private var start: Float?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let t: CGFloat = 24, w: CGFloat = 4
|
||||
let top = t / 2, bottom = geo.size.height - t / 2, mid = (top + bottom) / 2
|
||||
let yy = mid - (bottom - top) / 2 * CGFloat(value)
|
||||
Canvas { ctx, size in
|
||||
let x = size.width / 2
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: top, width: w, height: bottom - top), cornerRadius: w / 2), with: .color(p.fillStrong))
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: min(yy, mid), width: w, height: abs(yy - mid)), cornerRadius: w / 2), with: .color(p.accent))
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x - 8, y: mid)); $0.addLine(to: CGPoint(x: x + 8, y: mid)) }, with: .color(p.label3), lineWidth: 1)
|
||||
let thumb = Path(ellipseIn: CGRect(x: x - t / 2, y: yy - t / 2, width: t, height: t))
|
||||
ctx.fill(thumb, with: .color(.white))
|
||||
ctx.stroke(thumb, with: .color(.black.opacity(0.12)), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 2)
|
||||
.onChanged { g in
|
||||
let s = start ?? value
|
||||
if start == nil { start = value }
|
||||
var v = min(max(s - 2 * Float(g.translation.height / (geo.size.height - t)), -1), 1)
|
||||
if abs(v) < 0.04 { v = 0 }
|
||||
if v == 0 && value != 0 { UISelectionFeedbackGenerator().selectionChanged() }
|
||||
onChange(v)
|
||||
}
|
||||
.onEnded { _ in start = nil })
|
||||
.onTapGesture(count: 2) { onChange(0) }
|
||||
}
|
||||
.frame(width: 32)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- main EQ screen -------------------------------------------------------------------------
|
||||
|
||||
struct EqScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
let state = eq.state
|
||||
let device = eq.device
|
||||
let linked = eq.profileFor(device)
|
||||
let (icon, bg) = deviceIcon(device)
|
||||
List {
|
||||
Section {
|
||||
toggle("Equalizer", "slider.vertical.3", accentHex, Binding(get: { state.enabled }, set: { eq.setEnabled($0) }))
|
||||
toggle("Anti-Clipping Preamp", "waveform.path.ecg", 0xFF3B30, Binding(get: { state.antiClip }, set: { eq.setAntiClip($0) }))
|
||||
} footer: {
|
||||
Text("Anti-clipping lowers the volume by the curve's highest boost so loud songs don't distort.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { deviceSheet(device) } label: {
|
||||
ValueCell(title: device.label, value: linked?.name ?? "Default", icon: icon, iconBg: bg).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Picker("Mode", selection: Binding(get: { state.automatic }, set: { eq.setAutomatic($0) })) {
|
||||
Text("Automatic").tag(true)
|
||||
Text("Manual").tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented).themedRow()
|
||||
} header: { Text("Output") } footer: {
|
||||
Text(state.automatic ? "Switches profile when you change headphones or speakers." : "Keeps the profile you picked on every output.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
if let pr = eq.active {
|
||||
let preamp = effectivePreamp(pr)
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: state.enabled, range: EQ_MAX_DB + 3, bandMarkers: true)
|
||||
.frame(height: 64)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(EQ_BANDS.indices, id: \.self) { i in
|
||||
let v = pr.gains[i]
|
||||
VStack(spacing: 4) {
|
||||
Text(db(v)).font(.caption2.monospacedDigit()).foregroundStyle(abs(v) > 0.05 ? p.accent : p.label2)
|
||||
.lineLimit(1).fixedSize()
|
||||
VerticalBandSlider(value: v / EQ_MAX_DB) { eq.setGain(pr.id, band: i, db: round1($0 * EQ_MAX_DB)) }
|
||||
.frame(height: 124)
|
||||
Text(EQ_LABELS[i]).font(.caption2).foregroundStyle(p.label2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Preamp").font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
Text(abs(preamp) < 0.05 ? "off" : "\(db(preamp)) dB").font(.subheadline.monospacedDigit()).foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: {
|
||||
Text(pr.name + (state.automatic ? "" : " (manual)"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { profilesSheet(pr, device) } label: {
|
||||
ValueCell(title: "Profile", value: pr.name).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
.onLongPressGesture { profileMenu(pr, device) }
|
||||
let on = pr.filters.filter(\.on).count
|
||||
NavigationLink(value: Route.EqFilters(profileId: pr.id)) {
|
||||
ValueCell(title: "Parametric Filters", value: pr.filters.isEmpty ? "None" : "\(on) on")
|
||||
}
|
||||
.themedRow()
|
||||
Button {
|
||||
ui.overlays.actions("Load Preset", EQ_PRESETS.map { name, gains in SheetAction(name) { eq.setGains(pr.id, gains) } })
|
||||
} label: {
|
||||
ValueCell(title: "Presets", value: EQ_PRESETS.prefix(2).map(\.0).joined(separator: ", ") + "…").contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Equalizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { eq.refreshRoute() }
|
||||
}
|
||||
|
||||
private var accentHex: UInt32 { g.settings.accent.light }
|
||||
|
||||
private func toggle(_ title: String, _ icon: String, _ bg: UInt32, _ on: Binding<Bool>) -> some View {
|
||||
Toggle(isOn: on) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: bg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
|
||||
private func deviceSheet(_ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = [SheetAction("Use Default") {
|
||||
for pr in eq.state.profiles where pr.devices.contains(device) { eq.link(pr.id, device, false) }
|
||||
}]
|
||||
acts += eq.state.profiles.map { pr in SheetAction(pr.name) { eq.link(pr.id, device, true) } }
|
||||
ui.overlays.actions("Profile for \(device.label)", acts)
|
||||
}
|
||||
|
||||
private func profilesSheet(_ current: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = eq.state.profiles.map { pr in
|
||||
let devs = pr.devices.map(\.label).joined(separator: ", ")
|
||||
return SheetAction((pr.id == current.id ? "✓ " : "") + pr.name + (devs.isEmpty ? "" : " · \(devs)")) { eq.select(pr.id) }
|
||||
}
|
||||
acts.append(SheetAction("New Profile…", icon: "plus") {
|
||||
ui.overlays.prompt("New EQ Profile", "Starts from the current curve.", "Create", placeholder: "e.g. Sony WH-1000XM5") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
let made = eq.create(n, from: current)
|
||||
eq.link(made.id, device, true)
|
||||
eq.select(made.id)
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Manage “\(current.name)”…", icon: "ellipsis.circle") { profileMenu(current, device) })
|
||||
ui.overlays.actions("Profiles", acts)
|
||||
}
|
||||
|
||||
private func profileMenu(_ pr: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
let linked = pr.devices.contains(device)
|
||||
var acts = [
|
||||
SheetAction(linked ? "Unlink from \(device.label)" : "Use for \(device.label)", icon: linked ? "link.badge.plus" : "link") {
|
||||
eq.link(pr.id, device, !linked)
|
||||
},
|
||||
]
|
||||
if eq.state.defaultId != pr.id {
|
||||
acts.append(SheetAction("Make Default", icon: "star") { eq.setDefault(pr.id) })
|
||||
}
|
||||
acts.append(SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Profile", nil, initial: pr.name, "Save") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
if !n.isEmpty { eq.rename(pr.id, n) }
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Duplicate", icon: "plus.square.on.square") { eq.select(eq.create("\(pr.name) Copy", from: pr).id) })
|
||||
if eq.state.profiles.count > 1 {
|
||||
acts.append(SheetAction("Delete Profile", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Delete “\(pr.name)”?", "Outputs linked to it go back to the default profile.", "Delete") { eq.delete(pr.id) }
|
||||
})
|
||||
}
|
||||
ui.overlays.actions(pr.name, acts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- filter list ----------------------------------------------------------------------------
|
||||
|
||||
struct EqFiltersScreen: View {
|
||||
let profileId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var curveWidth: CGFloat = 1
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId) {
|
||||
let preamp = effectivePreamp(pr)
|
||||
List {
|
||||
Section {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: eq.state.enabled, nodes: pr.filters, labels: true)
|
||||
.frame(height: 170)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { curveWidth = $0 }
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { o in tap(o, pr) }
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} header: { Text(pr.name) } footer: {
|
||||
Text("Tap a numbered handle to edit that filter. Right-channel filters are orange. Paste an AutoEQ ParametricEQ.txt (from the ••• menu) to load a headphone correction.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
FaderSlider(value: (pr.preamp + EqController.preampRange) / (2 * EqController.preampRange), fill: p.accent, detent: 0.5) { v in
|
||||
eq.setPreamp(profileId, round1((v * 2 - 1) * EqController.preampRange))
|
||||
}
|
||||
ValueLabel(text: signedDb(pr.preamp), width: 76) {
|
||||
ui.overlays.prompt("Preamp", "In dB, −24 to +24.", initial: String(format: "%.1f", pr.preamp), "Set") { s in
|
||||
if let v = parseNumber(s) { eq.setPreamp(profileId, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Preamp") } footer: {
|
||||
if eq.state.antiClip {
|
||||
Text("Anti-clipping adds \(signedDb(preamp - pr.preamp)) on top, so the total is \(signedDb(preamp)).").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(Array(pr.filters.enumerated()), id: \.element.id) { i, f in
|
||||
NavigationLink(value: Route.EqFilterPage(profileId: profileId, filterId: f.id)) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(i + 1). \(f.type.label) · \(hz(f.freq))").foregroundStyle(f.on ? p.label : p.label2)
|
||||
let s = summary(f)
|
||||
if !s.isEmpty { Text(s).font(.subheadline).foregroundStyle(p.label2) }
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(get: { f.on }, set: { on in var nf = f; nf.on = on; eq.putFilter(profileId, nf) }))
|
||||
.labelsHidden().tint(Color(hex: 0x34C759))
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
.contextMenu {
|
||||
Button { var nf = f; nf.id = EqController.newFilterId(); eq.putFilter(profileId, nf) } label: { Label("Duplicate", systemImage: "plus.square.on.square") }
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
Button { add() } label: { Text("Add Filter…").foregroundStyle(p.accent) }.themedRow()
|
||||
} header: { Text("Filters") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button { apoMenu(pr) } label: { Image(systemName: "ellipsis.circle") }
|
||||
Button { add() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func tap(_ o: CGPoint, _ pr: EqProfile) {
|
||||
let w = curveWidth, h: CGFloat = 170
|
||||
let dist: (EqFilter) -> CGFloat = { f in
|
||||
let dx = CGFloat(freqToPos(f.freq)) * w - o.x
|
||||
let dy = (f.type.hasGain ? h / 2 - CGFloat(f.gain) / 18 * h / 2 : h / 2) - o.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
guard let hit = pr.filters.min(by: { dist($0) < dist($1) }),
|
||||
abs(CGFloat(freqToPos(hit.freq)) * w - o.x) < 40 else { return }
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: hit.id))
|
||||
}
|
||||
|
||||
private func add() {
|
||||
let eq = g.eq
|
||||
ui.overlays.actions("Add Filter", FilterType.allCases.map { t in
|
||||
SheetAction(t.label) {
|
||||
let f = newFilter(t)
|
||||
eq.putFilter(profileId, f)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: f.id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func apoMenu(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
var acts = [
|
||||
SheetAction("Paste APO Config", icon: "doc.on.clipboard") { importApo(pr) },
|
||||
SheetAction("Copy as APO Config", icon: "doc.on.doc") {
|
||||
UIPasteboard.general.string = ApoConfig.format(pr)
|
||||
ui.overlays.hud("Copied")
|
||||
},
|
||||
]
|
||||
if !pr.filters.isEmpty {
|
||||
acts.append(SheetAction("Remove All Filters", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Filters?", "The graphic EQ bands stay as they are.", "Remove All") {
|
||||
eq.setFilters(profileId, [], preamp: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.overlays.actions("Equalizer APO / AutoEQ", acts)
|
||||
}
|
||||
|
||||
private func importApo(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
guard let parsed = ApoConfig.parse(UIPasteboard.general.string ?? "") else {
|
||||
ui.overlays.hud("No Equalizer APO filters on the clipboard", "info.circle")
|
||||
return
|
||||
}
|
||||
func plural(_ n: Int, _ w: String) -> String { "\(n) \(w)\(n == 1 ? "" : "s")" }
|
||||
var what: [String] = []
|
||||
if !parsed.filters.isEmpty { what.append(plural(parsed.filters.count, "filter")) }
|
||||
if let pre = parsed.preamp { what.append("preamp \(signedDb(pre))") }
|
||||
let w = what.joined(separator: " and ")
|
||||
let skipped = parsed.skipped > 0 ? " \(plural(parsed.skipped, "line")) it doesn't understand (GraphicEQ, Delay…) will be skipped." : ""
|
||||
ui.overlays.confirm("Replace Filters?", "Loads \(w) into “\(pr.name)”, replacing its \(plural(pr.filters.count, "filter")).\(skipped)", "Replace", destructive: false) {
|
||||
eq.setFilters(profileId, parsed.filters, preamp: parsed.preamp)
|
||||
ui.overlays.hud("Imported \(w)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ValueLabel: View {
|
||||
let text: String
|
||||
let width: CGFloat
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.subheadline.monospacedDigit()).foregroundStyle(p.accent).frame(width: width, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- one filter -----------------------------------------------------------------------------
|
||||
|
||||
struct EqFilterPage: View {
|
||||
let profileId: String
|
||||
let filterId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId), let i = pr.filters.firstIndex(where: { $0.id == filterId }) {
|
||||
let f = pr.filters[i]
|
||||
List {
|
||||
Section {
|
||||
GeometryReader { geo in
|
||||
EqCurve(chain: pr.chain, preampDb: 0, enabled: eq.state.enabled && f.on, nodes: pr.filters, highlight: f, labels: true)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0).onChanged { v in
|
||||
guard let cur = eq.profile(profileId)?.filters.first(where: { $0.id == filterId }) else { return }
|
||||
let pos = Float(min(max(v.location.x / geo.size.width, 0), 1))
|
||||
var nf = cur
|
||||
nf.freq = tidyFreq(posToFreq(pos))
|
||||
if cur.type.hasGain {
|
||||
nf.gain = round1(min(max(18 * (1 - 2 * Float(v.location.y / geo.size.height)), -18), 18))
|
||||
}
|
||||
eq.putFilter(profileId, nf)
|
||||
})
|
||||
}
|
||||
.frame(height: 190)
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasGain ? "Drag the handle: sideways for frequency, up and down for gain." : "Drag the handle sideways to change the frequency.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
ui.overlays.actions("Filter Type", FilterType.allCases.map { t in
|
||||
SheetAction((t == f.type ? "✓ " : "") + t.label) {
|
||||
var nf = f
|
||||
nf.type = t
|
||||
if t == .Notch && f.type != .Notch { nf.q = 4 }
|
||||
put(nf)
|
||||
}
|
||||
})
|
||||
} label: {
|
||||
HStack {
|
||||
ValueCell(title: "Type", value: f.type.label)
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Toggle("On", isOn: Binding(get: { f.on }, set: { var nf = f; nf.on = $0; put(nf) }))
|
||||
.tint(Color(hex: 0x34C759)).foregroundStyle(p.label).themedRow()
|
||||
}
|
||||
|
||||
Section {
|
||||
param("Frequency", hz(f.freq), prompt: { number("Frequency", "In Hz, 10 to 22000.", f.freq) { var nf = f; nf.freq = $0; put(nf) } }) {
|
||||
FaderSlider(value: freqToPos(f.freq), fill: p.accent) { var nf = f; nf.freq = tidyFreq(posToFreq($0)); put(nf) }
|
||||
}
|
||||
if f.type.hasGain {
|
||||
param("Gain", signedDb(f.gain), prompt: { number("Gain", "In dB, −24 to +24.", f.gain) { var nf = f; nf.gain = $0; put(nf) } }) {
|
||||
FaderSlider(value: (f.gain + EqController.filterGain) / (2 * EqController.filterGain), fill: p.accent, detent: 0.5) {
|
||||
var nf = f; nf.gain = round1(($0 * 2 - 1) * EqController.filterGain); put(nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.type.hasSlope || f.slope == 12 {
|
||||
param("Q", String(format: "%.2f", f.q), prompt: { number("Q", "0.1 (broad) to 20 (narrow). One octave is 1.41.", f.q) { var nf = f; nf.q = $0; put(nf) } }) {
|
||||
FaderSlider(value: qToPos(f.q), fill: p.accent) { var nf = f; nf.q = (posToQ($0) * 100).rounded() / 100; put(nf) }
|
||||
}
|
||||
}
|
||||
if f.type.hasSlope {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Slope (dB per octave)").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Slope", selection: Binding(get: { f.slope }, set: { var nf = f; nf.slope = $0; put(nf) })) {
|
||||
ForEach(EQ_SLOPES, id: \.self) { Text("\($0)").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Channel").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Channel", selection: Binding(get: { f.channel }, set: { var nf = f; nf.channel = $0; put(nf) })) {
|
||||
ForEach(EqChannel.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasSlope && f.slope != 12 ? "Steeper than 12 dB/oct is a Butterworth response, so there's no Q."
|
||||
: f.type == .AllPass ? "An all-pass changes phase around its frequency, not level, so the curve stays flat."
|
||||
: "Tap a value to type it.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("\(i + 1). \(f.type.label)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
ui.overlays.actions("\(f.type.label) · \(hz(f.freq))", [
|
||||
SheetAction("Duplicate", icon: "plus.square.on.square") {
|
||||
var copy = f
|
||||
copy.id = EqController.newFilterId()
|
||||
put(copy)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: copy.id))
|
||||
},
|
||||
SheetAction("Delete Filter", icon: "trash", destructive: true) { eq.removeFilter(profileId, f.id) },
|
||||
])
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func put(_ f: EqFilter) { g.eq.putFilter(profileId, f) }
|
||||
|
||||
private func number(_ title: String, _ hint: String, _ initial: Float, _ set: @escaping (Float) -> Void) {
|
||||
ui.overlays.prompt(title, hint, initial: trimmed(initial), "Set") { s in
|
||||
if let v = parseNumber(s) { set(v) }
|
||||
}
|
||||
}
|
||||
|
||||
private func param<S: View>(_ title: String, _ value: String, prompt: @escaping () -> Void, @ViewBuilder slider: () -> S) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text(title).font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
ValueLabel(text: value, width: 110, action: prompt)
|
||||
}
|
||||
slider()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import SwiftUI
|
||||
|
||||
// ---- library home ---------------------------------------------------------------------------
|
||||
|
||||
struct LibraryHome: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@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 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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
SectionTitle(title: "On This Day")
|
||||
Text(today.formatted(.dateTime.month(.wide).day())).font(.subheadline).foregroundStyle(p.label2)
|
||||
.padding(.leading, MARGIN).padding(.bottom, 10)
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
let (onDay, week) = birthdays(list, today)
|
||||
if !onDay.isEmpty {
|
||||
AlbumGrid(albums: onDay.map(\.album), subtitle: { a in
|
||||
onDay.first { $0.album.ratingKey == a.ratingKey }.map { "\(a.artist) · \(yearsAgo($0.years))" }
|
||||
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
} else {
|
||||
Text(week.isEmpty ? "No albums in your library came out this week in any year." : "No album birthdays today. Coming up and just past:")
|
||||
.font(.subheadline).foregroundStyle(p.label2).lineLimit(3)
|
||||
.padding(.horizontal, MARGIN).padding(.bottom, 12)
|
||||
if !week.isEmpty {
|
||||
AlbumGrid(albums: week.map(\.album), subtitle: { a in
|
||||
week.first { $0.album.ratingKey == a.ratingKey }.map { b in
|
||||
let verb = b.date < Calendar.current.startOfDay(for: today) ? "Turned" : "Turns"
|
||||
return "\(verb) \(b.years) · \(b.date.formatted(.dateTime.month(.abbreviated).day()))"
|
||||
}
|
||||
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.musicSection)|\(reload)") {
|
||||
guard ready else { return }
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An album's anniversary: its release day this year, and how old it is then.
|
||||
private struct Birthday { let album: Album; let date: Date; let years: Int }
|
||||
|
||||
private func yearsAgo(_ n: Int) -> String { n == 1 ? "1 year ago" : "\(n) years ago" }
|
||||
|
||||
/// Albums released on today's date in earlier years, and, for slow days, the three days either side.
|
||||
private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday]) {
|
||||
let cal = Calendar(identifier: .gregorian)
|
||||
let today = cal.startOfDay(for: now)
|
||||
let year = cal.component(.year, from: today)
|
||||
let all: [Birthday] = albums.compactMap { a in
|
||||
guard let r = a.released, r.count >= 10 else { return nil }
|
||||
let parts = r.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3, parts[0] < year else { return nil }
|
||||
var day = parts[2]
|
||||
// Feb 29 falls back to Feb 28 in other years.
|
||||
if parts[1] == 2, day == 29, cal.range(of: .day, in: .month, for: cal.date(from: DateComponents(year: year, month: 2))!)!.count < 29 { day = 28 }
|
||||
guard let d = cal.date(from: DateComponents(year: year, month: parts[1], day: day)) else { return nil }
|
||||
return Birthday(album: a, date: d, years: year - parts[0])
|
||||
}
|
||||
let onDay = all.filter { $0.date == today }.sorted { $0.years > $1.years }
|
||||
let week = all.filter { b in
|
||||
b.date != today && abs(cal.dateComponents([.day], from: today, to: b.date).day ?? 99) <= 3
|
||||
}.sorted { ($0.date, -$0.years) < ($1.date, -$1.years) }
|
||||
return (onDay, week)
|
||||
}
|
||||
|
||||
private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let last: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
|
||||
Text(title).font(.title3).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRow(inset: 60, trailing: 16, line: !last)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artists --------------------------------------------------------------------------------
|
||||
|
||||
struct ArtistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var artists: LoadState<[Artist]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
Loaded(state: artists, retry: { reload += 1 }) { list in
|
||||
ForEach(list) { a in
|
||||
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain)
|
||||
.actionsMenu(ui.menu.artist(a))
|
||||
.listRow(inset: MARGIN + 58, trailing: 28)
|
||||
.id(a.ratingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(p.bg)
|
||||
.overlay(alignment: .trailing) {
|
||||
if let list = artists.value, list.count > 30 {
|
||||
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func letterOf(_ s: String) -> Character {
|
||||
var t = s.drop { $0.isWhitespace }
|
||||
if t.hasPrefix("The ") { t = t.dropFirst(4) }
|
||||
guard let ch = t.first?.uppercased().first, ("A"..."Z").contains(ch) else { return "#" }
|
||||
return ch
|
||||
}
|
||||
|
||||
/// UITableView's section index: drag down the letters to jump.
|
||||
private struct SectionIndex: View {
|
||||
let titles: [String]
|
||||
let jump: (Int) -> Void
|
||||
@Environment(\.palette) private var p
|
||||
@State private var last: Character?
|
||||
private static let letters: [Character] = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ#")
|
||||
|
||||
var body: some View {
|
||||
let first: [Character: Int] = titles.enumerated().reduce(into: [:]) { m, e in
|
||||
let l = letterOf(e.element)
|
||||
if m[l] == nil { m[l] = e.offset }
|
||||
}
|
||||
GeometryReader { outer in
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Self.letters, id: \.self) { l in
|
||||
Text(String(l)).font(.caption2.weight(.semibold)).foregroundStyle(p.accent).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 22, height: outer.size.height * 0.62)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0)
|
||||
.onChanged { v in
|
||||
let h = outer.size.height * 0.62
|
||||
let i = min(max(Int(v.location.y / h * CGFloat(Self.letters.count)), 0), Self.letters.count - 1)
|
||||
let l = Self.letters[i]
|
||||
if l == last { return }
|
||||
last = l
|
||||
if let target = Self.letters[i...].lazy.compactMap({ first[$0] }).first { jump(target) }
|
||||
}
|
||||
.onEnded { _ in last = nil })
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(width: 24)
|
||||
.sensoryFeedback(.selection, trigger: last) { _, new in new != nil }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- albums ---------------------------------------------------------------------------------
|
||||
|
||||
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
|
||||
|
||||
struct AlbumsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var sort = 0
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Albums") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
|
||||
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- songs (paged) --------------------------------------------------------------------------
|
||||
|
||||
struct SongsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var tracks: [Track] = []
|
||||
@State private var total = -1
|
||||
@State private var loading = false
|
||||
@State private var error: String?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Songs") {
|
||||
if !tracks.isEmpty {
|
||||
if total > 0 {
|
||||
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
}
|
||||
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
||||
shuffle: { g.playback.playTracks(Array(tracks.prefix(1000)), shuffle: true) })
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) {
|
||||
let from = max(i - 200, 0)
|
||||
g.playback.playTracks(Array(tracks[from..<min(i + 800, tracks.count)]), start: i - from)
|
||||
}
|
||||
.listRow(inset: MARGIN + 60)
|
||||
.onAppear { if i > tracks.count - 40 { loadMore() } }
|
||||
}
|
||||
if loading { LoadingRow() }
|
||||
if let error { ErrorRow(message: error) { loadMore() } }
|
||||
}
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
}
|
||||
|
||||
private func loadMore() {
|
||||
if loading || (total >= 0 && tracks.count >= total) { return }
|
||||
loading = true
|
||||
Task {
|
||||
do {
|
||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
|
||||
tracks += page; total = n; error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- album & artist pages -------------------------------------------------------------------
|
||||
|
||||
struct AlbumScreen: View {
|
||||
let album: Album
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
@State private var tracks: LoadState<[Track]> = .loading
|
||||
@State private var detail: Album?
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
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 }
|
||||
Page(title: a.title, large: false) {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
|
||||
Text(a.title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||
.padding(.horizontal, 32).padding(.top, 16)
|
||||
Button {
|
||||
if let k = a.artistKey { ui.push(.ArtistPage(Artist(ratingKey: k, title: a.artist, sortTitle: a.artist))) }
|
||||
} label: {
|
||||
Text(a.artist).font(.title3).foregroundStyle(p.accent).multilineTextAlignment(.center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
let meta = [a.genre?.uppercased(), a.year.map(String.init)].compactMap { $0 }.joined(separator: " · ")
|
||||
if !meta.isEmpty { Text(meta).font(.caption.weight(.semibold)).foregroundStyle(p.label2).padding(.top, 4) }
|
||||
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) })
|
||||
.padding(.vertical, 16)
|
||||
p.separator.frame(height: 1 / scale).padding(.leading, MARGIN)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
Loaded(state: tracks, retry: { reload += 1 }) { list in
|
||||
let discs = Set(list.compactMap(\.disc)).count > 1
|
||||
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
|
||||
if discs, let d = t.disc, i == 0 || list[i - 1].disc != d {
|
||||
Text("Disc \(d)").font(.headline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 18).padding(.bottom, 6)
|
||||
}
|
||||
TrackRow(track: t, number: t.index ?? i + 1, showArt: false, subtitle: t.artist != a.artist ? t.artist : nil,
|
||||
menu: ui.menu.track(t, allowGoTo: false)) { g.playback.playTracks(list, start: i) }
|
||||
.listRow(inset: MARGIN + 32)
|
||||
}
|
||||
Text("\(songs(list.count)), \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))")
|
||||
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
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) }
|
||||
} else {
|
||||
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
}
|
||||
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
|
||||
MoreMenu(items: ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: reload) {
|
||||
async let d = try? g.plex.album(album.ratingKey)
|
||||
await LoadState.load($tracks) { try await g.plex.albumTracks(album.ratingKey) }
|
||||
if let d = await d ?? nil { detail = d }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistScreen: View {
|
||||
let artist: Artist
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: artist.title) {
|
||||
PlayShuffle(play: { playAll(false) }, shuffle: { playAll(true) }).padding(.top, 6).padding(.bottom, 18)
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list, showArtist: false, menu: { ui.menu.album($0, allowGoTo: false) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { MoreMenu(items: ui.menu.artist(artist)) } }
|
||||
.task(id: reload) {
|
||||
await LoadState.load($albums) { try await g.plex.artistAlbums(artist.ratingKey).sorted { ($0.year ?? 0) > ($1.year ?? 0) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func playAll(_ shuffle: Bool) {
|
||||
Task {
|
||||
do { g.playback.playTracks(try await g.plex.artistTracks(artist.ratingKey), shuffle: shuffle) }
|
||||
catch { ui.overlays.hud("Couldn't load songs", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------
|
||||
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
|
||||
|
||||
/// Downloaded songs gathered back into their albums.
|
||||
private struct DownloadedAlbum {
|
||||
let album: Album
|
||||
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
|
||||
}
|
||||
|
||||
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 pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
|
||||
Page(title: "Downloads") {
|
||||
if albums.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.")
|
||||
} else {
|
||||
Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(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))
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !albums.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
|
||||
var a: [SheetAction] = []
|
||||
if albums.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") {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum NpPanel { case Art, Mixer, Queue }
|
||||
|
||||
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
||||
switch d.kind {
|
||||
case .Speaker: ("iphone", 0x8E8E93)
|
||||
case .Bluetooth: ("headphones", 0x007AFF)
|
||||
case .Wired: ("headphones", 0x34C759)
|
||||
case .Usb: ("cable.connector", 0xFF9500)
|
||||
case .Hdmi: ("airplayaudio", 0x5856D6)
|
||||
case .Car: ("car.fill", 0x34C759)
|
||||
case .Other: ("hifispeaker", 0x8E8E93)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- album colour ---------------------------------------------------------------------------
|
||||
|
||||
/// A solid colour from the album art, pushed dark (or light for the White theme) so text
|
||||
/// stays readable. One flat colour: no gradients.
|
||||
@MainActor
|
||||
private func albumColor(_ thumb: String?, dark: Bool) async -> Color? {
|
||||
guard let url = AppGraph.shared.plex.artUrl(thumb, px: 120) else { return nil }
|
||||
let img: UIImage? = if let c = ArtLoader.shared.cached(url) { c } else { await ArtLoader.shared.image(url) }
|
||||
guard let cg = img?.cgImage else { return nil }
|
||||
return await Task.detached { () -> Color? in
|
||||
guard let (h, s0, v0) = vibrant(cg) else { return nil }
|
||||
let s = dark ? min(s0, 0.65) : min(s0, 0.22)
|
||||
let v = dark ? min(max(v0, 0.18), 0.30) : 0.96
|
||||
return Color(hue: h, saturation: s, brightness: v)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Roughly Palette's vibrant swatch: the average hue of the most saturated, mid-bright pixels.
|
||||
private func vibrant(_ cg: CGImage) -> (CGFloat, CGFloat, CGFloat)? {
|
||||
let n = 24
|
||||
var px = [UInt8](repeating: 0, count: n * n * 4)
|
||||
guard let ctx = CGContext(data: &px, width: n, height: n, bitsPerComponent: 8, bytesPerRow: n * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
|
||||
ctx.draw(cg, in: CGRect(x: 0, y: 0, width: n, height: n))
|
||||
var all: [(CGFloat, CGFloat, CGFloat, CGFloat)] = []
|
||||
for i in 0..<(n * n) {
|
||||
let c = UIColor(red: CGFloat(px[i * 4]) / 255, green: CGFloat(px[i * 4 + 1]) / 255, blue: CGFloat(px[i * 4 + 2]) / 255, alpha: 1)
|
||||
var h: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0, a: CGFloat = 0
|
||||
c.getHue(&h, saturation: &s, brightness: &v, alpha: &a)
|
||||
// Favour colourful pixels that aren't near black or white.
|
||||
let score = s * (1 - abs(v - 0.55) * 1.4)
|
||||
all.append((h, s, v, score))
|
||||
}
|
||||
let top = all.sorted { $0.3 > $1.3 }.prefix(max(1, all.count / 6))
|
||||
// Average hue on the circle so reds either side of 0 don't cancel out.
|
||||
var x: CGFloat = 0, y: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0
|
||||
for p in top { x += cos(p.0 * 2 * .pi); y += sin(p.0 * 2 * .pi); s += p.1; v += p.2 }
|
||||
let k = CGFloat(top.count)
|
||||
var h = atan2(y, x) / (2 * .pi)
|
||||
if h < 0 { h += 1 }
|
||||
return (h, s / k, v / k)
|
||||
}
|
||||
|
||||
private extension Palette {
|
||||
func tinted(_ bg: Color) -> Palette {
|
||||
let dark = UIColor(bg).luminance < 0.5
|
||||
var c = self
|
||||
c.dark = dark
|
||||
c.bg = bg
|
||||
c.groupedBg = bg
|
||||
c.fill = dark ? .white.opacity(0.12) : .black.opacity(0.06)
|
||||
c.fillStrong = dark ? .white.opacity(0.22) : .black.opacity(0.12)
|
||||
c.label = dark ? .white : .black
|
||||
c.label2 = dark ? .white.opacity(0.62) : .black.opacity(0.55)
|
||||
c.label3 = dark ? .white.opacity(0.25) : .black.opacity(0.2)
|
||||
c.separator = dark ? .white.opacity(0.14) : .black.opacity(0.1)
|
||||
c.placeholder = c.fill
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var luminance: CGFloat {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
}
|
||||
|
||||
// ---- now playing ----------------------------------------------------------------------------
|
||||
|
||||
struct NowPlayingScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var base
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var panel = NpPanel.Art
|
||||
@State private var tint: Color?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let c = tint.map { base.tinted($0) } ?? base
|
||||
let track = pm.current?.track
|
||||
VStack(spacing: 0) {
|
||||
if let track {
|
||||
Group {
|
||||
switch panel {
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.18), value: panel)
|
||||
controls(track)
|
||||
} else {
|
||||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.environment(\.palette, c)
|
||||
.preferredColorScheme(c.dark ? .dark : .light)
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.presentationDragIndicator(.visible)
|
||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||
tint = await albumColor(thumb, dark: base.dark)
|
||||
}
|
||||
}
|
||||
|
||||
private func controls(_ track: Track) -> some View {
|
||||
let pm = g.playback
|
||||
let c = tint.map { base.tinted($0) } ?? base
|
||||
return VStack(spacing: 0) {
|
||||
if panel == .Art {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
||||
Text(track.artist).font(.title3).foregroundStyle(c.label2).lineLimit(1)
|
||||
.onTapGesture {
|
||||
if let k = track.artistKey { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: track.artist, sortTitle: track.artist))) }
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
EllipsisButton(items: ui.menu.track(track))
|
||||
}
|
||||
.padding(.top, 14)
|
||||
}
|
||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||
.padding(.top, 18)
|
||||
HStack {
|
||||
Spacer()
|
||||
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
||||
Spacer()
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView().controlSize(.large).tint(c.label) }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 48)).foregroundStyle(c.label) }
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 84)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { panel = panel == .Mixer ? .Art : .Mixer }
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { panel = panel == .Queue ? .Art : .Queue }
|
||||
}
|
||||
.frame(height: 48)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
|
||||
private func routeLabel(_ c: Palette) -> some View {
|
||||
let eq = g.eq
|
||||
let device = eq.device
|
||||
let eqLabel: String = {
|
||||
guard eq.state.enabled, let p = eq.active else { return "" }
|
||||
return p.name.caseInsensitiveCompare(device.label) == .orderedSame ? " · EQ" : " · \(p.name)"
|
||||
}()
|
||||
return HStack(spacing: 6) {
|
||||
Image(systemName: deviceIcon(device).0).font(.system(size: 14))
|
||||
Text(device.label + eqLabel).font(.footnote.weight(.medium)).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(c.label2)
|
||||
}
|
||||
|
||||
private func badge(_ track: Track) -> String? {
|
||||
let pm = g.playback
|
||||
switch pm.source {
|
||||
case .Stems:
|
||||
let q = g.settings.stemQuality
|
||||
return "\(pm.stemNames.count) STEMS · \(g.stems.effectiveQuality(q) == .Lossless ? "FLAC" : "OPUS \(q.kbps)")"
|
||||
case .Original:
|
||||
return ["ORIGINAL", track.codec?.uppercased()].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
|
||||
case .None:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades a little while pressed, like Apple Music's transport.
|
||||
struct PressFade: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label.opacity(configuration.isPressed ? 0.45 : 1).scaleEffect(configuration.isPressed ? 0.92 : 1)
|
||||
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TransportButton: View {
|
||||
let symbol: String
|
||||
let size: CGFloat
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
private struct PanelToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: 19, weight: .medium)).foregroundStyle(on ? p.bg : p.label2)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(on ? p.label : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.sensoryFeedback(.selection, trigger: on)
|
||||
}
|
||||
}
|
||||
|
||||
/// Round ••• button on a fill, opening the media menu.
|
||||
private struct EllipsisButton: View {
|
||||
let items: [SheetAction]
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button { ui.overlays.actions("", items) } label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: 15, weight: .bold)).foregroundStyle(p.label)
|
||||
.frame(width: 32, height: 32).background(p.fill, in: Circle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
/// Big art that eases back when paused, like Apple Music.
|
||||
private struct ArtPanel: View {
|
||||
let thumb: String?
|
||||
let playing: Bool
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let side = max(min(geo.size.width, geo.size.height), 1)
|
||||
Artwork(thumb: thumb, size: side, corner: 12)
|
||||
.shadow(color: .black.opacity(playing ? 0.35 : 0.15), radius: playing ? 24 : 10, y: 10)
|
||||
.scaleEffect(playing ? 1 : 0.82)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniHeader: View {
|
||||
let track: Track
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: track.thumb, size: 64, corner: 8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).font(.headline).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
EllipsisButton(items: ui.menu.track(track))
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek / volume bar ----------------------------------------------------------------------
|
||||
|
||||
/// 0..1 loudness per waveform bucket for the current mix: stems weighted by their faders.
|
||||
func envelope(_ w: Waveform?, _ names: [String], _ mix: [String: StemSetting]) -> [Float]? {
|
||||
guard let w else { return nil }
|
||||
if let stems = w.stems, !stems.isEmpty {
|
||||
let anySolo = names.contains { mix[$0]?.solo == true }
|
||||
var gains: [String: Float] = [:]
|
||||
for n in stems.keys {
|
||||
let s = mix[n] ?? StemSetting()
|
||||
gains[n] = (anySolo && !s.solo) || s.muted ? 0 : stemTaper(s.level)
|
||||
}
|
||||
// Scaled to the full mix's range, so muting a stem still shows as quieter bars.
|
||||
return shape(mixDb(stems, gains), mixDb(stems, nil))
|
||||
}
|
||||
if let db = w.loudnessDb { return shape(db, db) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func mixDb(_ stems: [String: [Float]], _ gains: [String: Float]?) -> [Float] {
|
||||
let n = stems.values.map(\.count).max() ?? 0
|
||||
return (0..<n).map { i in
|
||||
var e: Float = 0
|
||||
for (name, arr) in stems {
|
||||
let v = (i < arr.count ? arr[i] : 0) * (gains?[name] ?? 1)
|
||||
e += v * v
|
||||
}
|
||||
return 20 * log10(e.squareRoot() + 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loudness to bar height, relative to the song itself: its loudest moments (99th percentile)
|
||||
/// fill the bar and its quiet tenth sits near the bottom, over 6 to 24 dB, with a curve that
|
||||
/// pulls the middle down. Modern masters barely move in absolute terms, so an absolute scale
|
||||
/// draws nearly every song as a solid block.
|
||||
private func shape(_ db: [Float], _ ref: [Float]) -> [Float] {
|
||||
var audible = ref.filter { $0 > -60 }
|
||||
if audible.isEmpty { audible = ref }
|
||||
audible.sort()
|
||||
if audible.isEmpty { return db.map { _ in 0.28 } }
|
||||
func pct(_ p: Float) -> Float { audible[min(Int(p * Float(audible.count)), audible.count - 1)] }
|
||||
let hi = pct(0.99)
|
||||
let span = min(max(hi - pct(0.10), 6), 24)
|
||||
let lo = hi - span
|
||||
return db.map { 0.05 + 0.95 * pow(min(max(($0 - lo) / span, 0), 1), 1.8) }
|
||||
}
|
||||
|
||||
/// Plexamp's seek bar: the track's loudness as bars, played part highlighted. The bars' height
|
||||
/// follows the master volume, so the volume is drawn over the progress.
|
||||
/// Drag sideways to seek, up/down to change volume, tap to jump.
|
||||
struct ProgressVolumeBar: View {
|
||||
let played: Color
|
||||
let unplayed: Color
|
||||
let centerLabel: String?
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@State private var scrub: Double?
|
||||
@State private var axis = 0 // 1 = seek, 2 = volume
|
||||
@State private var startVol: Float = 0
|
||||
@State private var lastStep = 0
|
||||
@State private var haptic = 0
|
||||
@State private var barWidth: Double = 1
|
||||
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let master = pm.master
|
||||
let env = envelope(pm.waveform, pm.stemNames, pm.mix)
|
||||
let dur = pm.durationMs
|
||||
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
|
||||
let adjusting = axis == 2
|
||||
TimelineView(.animation(paused: !pm.playWhenReady || scrub != nil)) { _ in
|
||||
let pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
|
||||
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
|
||||
VStack(spacing: 4) {
|
||||
Canvas { ctx, size in
|
||||
let barW = 2.7, pitch = 4.6
|
||||
let count = max(8, Int(size.width / pitch))
|
||||
let gap = (size.width - Double(count) * barW) / Double(count - 1)
|
||||
let maxH = size.height, minH = barW, cy = size.height / 2
|
||||
if adjusting {
|
||||
let gh = maxH * volScale / 2
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0, y: cy - gh)); path.addLine(to: CGPoint(x: size.width, y: cy - gh))
|
||||
path.move(to: CGPoint(x: 0, y: cy + gh)); path.addLine(to: CGPoint(x: size.width, y: cy + gh))
|
||||
ctx.stroke(path, with: .color(p.label2.opacity(0.5)), lineWidth: 1)
|
||||
}
|
||||
var playedPath = Path(), restPath = Path()
|
||||
for i in 0..<count {
|
||||
var a: Float = 0.28
|
||||
if let env, !env.isEmpty {
|
||||
let from = i * env.count / count
|
||||
let to = min(max(from + 1, (i + 1) * env.count / count), env.count)
|
||||
a = env[from..<to].max() ?? 0
|
||||
}
|
||||
let h = minH + (maxH - minH) * Double(a) * volScale
|
||||
let x = Double(i) * (barW + gap)
|
||||
let r = CGRect(x: x, y: cy - h / 2, width: barW, height: h)
|
||||
if (x + barW / 2) / size.width <= frac { playedPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
|
||||
else { restPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
|
||||
}
|
||||
ctx.fill(playedPath, with: .color(played))
|
||||
ctx.fill(restPath, with: .color(unplayed))
|
||||
}
|
||||
.frame(height: 56)
|
||||
.onGeometryChange(for: Double.self) { Double($0.size.width) } action: { barWidth = max($0, 1) }
|
||||
.animation(.easeOut(duration: 0.09), value: master)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(drag(dur: dur))
|
||||
ZStack {
|
||||
HStack {
|
||||
Text(formatDuration(pos / 1000 * 1000))
|
||||
Spacer()
|
||||
Text("-" + formatDuration(max(0, dur - pos / 1000 * 1000)))
|
||||
}
|
||||
.font(.caption.weight(.medium).monospacedDigit())
|
||||
if adjusting {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: master <= 0.001 ? "speaker.slash.fill" : "speaker.wave.2.fill").font(.system(size: 11))
|
||||
Text("\(Int(master * 100))%").font(.caption.weight(.semibold).monospacedDigit())
|
||||
}
|
||||
} else if let centerLabel {
|
||||
Text(centerLabel).font(.caption2.bold()).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(p.label2)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: haptic)
|
||||
}
|
||||
|
||||
private func drag(dur: Int64) -> some Gesture {
|
||||
let pm = g.playback
|
||||
return DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { v in
|
||||
let t = v.translation
|
||||
if axis == 0, abs(t.width) > 8 || abs(t.height) > 8 {
|
||||
axis = abs(t.width) >= abs(t.height) ? 1 : 2
|
||||
startVol = pm.master
|
||||
lastStep = Int(startVol * 20)
|
||||
haptic += 1
|
||||
}
|
||||
switch axis {
|
||||
case 1: scrub = width(v) > 0 ? min(max(v.location.x / width(v), 0), 1) : 0
|
||||
case 2:
|
||||
let nv = min(max(startVol - Float(t.height / (56 * 4.5)), 0), 1)
|
||||
let step = Int(nv * 20)
|
||||
if step != lastStep {
|
||||
lastStep = step
|
||||
if nv == 0 || nv == 1 { haptic += 1 }
|
||||
}
|
||||
pm.setMaster(nv)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
.onEnded { v in
|
||||
switch axis {
|
||||
case 0: pm.seekTo(Int64(min(max(v.location.x / width(v), 0), 1) * Double(dur)))
|
||||
case 1: if let scrub { pm.seekTo(Int64(scrub * Double(dur))) }
|
||||
default: break
|
||||
}
|
||||
scrub = nil
|
||||
axis = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func width(_ v: DragGesture.Value) -> Double { barWidth }
|
||||
}
|
||||
|
||||
// ---- stem mixer -----------------------------------------------------------------------------
|
||||
|
||||
/// Meter levels with a quick attack and ~130 ms fall, timed in seconds so they look the same
|
||||
/// at 60 and 120 Hz. A plain class, so stepping it each frame doesn't invalidate views.
|
||||
private final class Meters {
|
||||
var raw = [Float](repeating: 0, count: MeterHistory.maxStems)
|
||||
var shown: [Float] = []
|
||||
var last: Int64 = 0
|
||||
|
||||
func step(_ engine: StemEngine?, _ n: Int) -> [Float] {
|
||||
if shown.count != n { shown = Array(repeating: 0, count: n) }
|
||||
let now = monoNanos()
|
||||
let fall: Float = last == 0 ? 0 : exp(-Float(now - last) / 1e9 / 0.13)
|
||||
last = now
|
||||
let live = engine?.levelsNow(&raw) ?? false
|
||||
for i in 0..<n { shown[i] = max(live && i < raw.count ? raw[i] : 0, shown[i] * fall) }
|
||||
return shown
|
||||
}
|
||||
}
|
||||
|
||||
private struct MixerPanel: View {
|
||||
@Environment(\.palette) private var p
|
||||
@State private var meters = Meters()
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let names = pm.stemNames
|
||||
let stems = pm.source == .Stems
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if pm.source == .Original {
|
||||
Text("No stems for this song yet, so you're hearing the original file. stemd on your server separates new music on its own; once this one's done, the app picks it up within a minute and plays it with stems next time.")
|
||||
.font(.footnote).foregroundStyle(p.label2)
|
||||
.padding(14).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
let current = MixPresets.normalized(pm.mix)
|
||||
ForEach(MixPresets.all) { preset in
|
||||
if let target = preset.build(names) {
|
||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
TimelineView(.animation(paused: !stems || !pm.playWhenReady)) { _ in
|
||||
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.separator.frame(height: 0.5).padding(.vertical, 10)
|
||||
HStack {
|
||||
Text("Master").font(.headline).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
|
||||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
||||
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 16)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||
private struct PresetChip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
||||
.padding(.horizontal, 14).frame(height: 32)
|
||||
.background(selected ? p.label : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sensoryFeedback(.selection, trigger: selected)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StemFader: View {
|
||||
let name: String
|
||||
let setting: StemSetting
|
||||
let level: Float
|
||||
let enabled: Bool
|
||||
@Environment(\.palette) private var p
|
||||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||||
|
||||
var body: some View {
|
||||
let color = Color(hex: Stems.color(name))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
|
||||
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
|
||||
Spacer()
|
||||
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
|
||||
if enabled {
|
||||
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
||||
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
||||
}
|
||||
}
|
||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||
pm.setStemLevel(name, $0)
|
||||
}
|
||||
// Live level under the fader, -48..0 dBFS.
|
||||
GeometryReader { geo in
|
||||
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(p.fill)
|
||||
Capsule().fill(color).frame(width: geo.size.width * f)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleBox: View {
|
||||
let text: String
|
||||
let on: Bool
|
||||
let onColor: Color
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
|
||||
.frame(width: 30, height: 26)
|
||||
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// An iOS-style slider with a coloured fill and, optionally, a detent that catches the thumb
|
||||
/// near one value (unity gain) with a tick.
|
||||
struct FaderSlider: View {
|
||||
let value: Float
|
||||
var fill: Color
|
||||
var detent: Float?
|
||||
var enabled = true
|
||||
var thumb: CGFloat = 24
|
||||
let onChange: (Float) -> Void
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@State private var start: Float?
|
||||
@State private var caught = false
|
||||
|
||||
init(value: Float, fill: Color, detent: Float? = nil, enabled: Bool = true, thumb: CGFloat = 24, onChange: @escaping (Float) -> Void) {
|
||||
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let track = max(geo.size.width - thumb, 1)
|
||||
let x = CGFloat(min(max(value, 0), 1)) * track
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2)
|
||||
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01)
|
||||
if let detent {
|
||||
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
|
||||
}
|
||||
Circle().fill(.white).frame(width: thumb, height: thumb)
|
||||
.shadow(color: .black.opacity(0.25), radius: 3, y: 1)
|
||||
.offset(x: x)
|
||||
}
|
||||
.frame(height: geo.size.height)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0).onChanged { g in
|
||||
guard enabled else { return }
|
||||
if start == nil { start = value }
|
||||
var v = min(max((start ?? value) + Float(g.translation.width / track), 0), 1)
|
||||
if let detent {
|
||||
let near = abs(v - detent) < 0.025
|
||||
if near { v = detent }
|
||||
if near != caught { caught = near }
|
||||
}
|
||||
onChange(v)
|
||||
}.onEnded { _ in start = nil })
|
||||
}
|
||||
.frame(height: 36)
|
||||
.opacity(enabled ? 1 : 0.4)
|
||||
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- queue ----------------------------------------------------------------------------------
|
||||
|
||||
private struct QueuePanel: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let first = pm.index + 1
|
||||
let upcoming = Array(pm.queue.dropFirst(max(first, 0)))
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Playing Next").font(.headline).foregroundStyle(p.label)
|
||||
Text(songs(upcoming.count)).font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer()
|
||||
QueueToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
QueueToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
if !upcoming.isEmpty {
|
||||
Button("Clear") { pm.clearUpNext() }.foregroundStyle(p.label2).padding(.leading, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 6)
|
||||
// Hold a row to drag it; swipe for Play Next or Remove.
|
||||
List {
|
||||
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
|
||||
HStack(spacing: 12) {
|
||||
Artwork(thumb: q.track.thumb, size: 44, corner: 5)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(q.track.title).foregroundStyle(p.label).lineLimit(1)
|
||||
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())
|
||||
.onTapGesture { pm.skipTo(first + i) }
|
||||
.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) {
|
||||
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
guard let f = from.first else { return }
|
||||
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.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(on ? p.bg : p.label2)
|
||||
.frame(width: 36, height: 30)
|
||||
.background(on ? p.label : p.fill, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mini player ----------------------------------------------------------------------------
|
||||
|
||||
struct MiniPlayer: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
if let t = pm.current?.track {
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 46, corner: 6).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 24)) }
|
||||
}
|
||||
.foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Button(action: pm.next) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 22)).foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
.padding(.leading, 16).padding(.trailing, 8)
|
||||
.frame(height: 64)
|
||||
.background(.bar)
|
||||
.overlay(alignment: .top) { p.separator.frame(height: 1 / scale) }
|
||||
.overlay(alignment: .bottom) {
|
||||
// Hairline progress along the bottom edge.
|
||||
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
|
||||
let d = pm.durationMs
|
||||
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
|
||||
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
|
||||
.actionsMenu(ui.menu.track(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import SwiftUI
|
||||
|
||||
// ---- playlists ------------------------------------------------------------------------------
|
||||
|
||||
struct PlaylistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var lists: LoadState<[Playlist]> = .loading
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Playlists") {
|
||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
||||
if list.isEmpty {
|
||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
||||
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
|
||||
}
|
||||
ForEach(list) { pl in
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($lists) { try await g.plex.playlists() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Plex can't hold an empty playlist, so new ones start from songs you have.
|
||||
private func newPlaylist() {
|
||||
let queue = g.playback.queue
|
||||
var acts: [SheetAction] = []
|
||||
if !queue.isEmpty {
|
||||
acts.append(SheetAction("New Playlist from Queue (\(queue.count))") { ui.menu.newPlaylist(queue.map(\.track)) { reload += 1 } })
|
||||
}
|
||||
if let now = g.playback.current?.track {
|
||||
acts.append(SheetAction("New Playlist with “\(now.title)”") { ui.menu.newPlaylist([now]) { reload += 1 } })
|
||||
}
|
||||
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
||||
else { ui.overlays.actions("New Playlist", acts) }
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistRow: View {
|
||||
let playlist: Playlist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "music.note.list")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(songs(playlist.trackCount)).font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if playlist.smart { Tag(text: "SMART") }
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 76)
|
||||
.contentShape(Rectangle())
|
||||
.listRow(inset: 94, trailing: 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistScreen: View {
|
||||
let playlist: Playlist
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var tracks: LoadState<[Track]> = .loading
|
||||
@State private var reload = 0
|
||||
@State private var title: String
|
||||
@State private var editing = false
|
||||
@State private var items: [Track] = []
|
||||
@State private var removed: [Track] = []
|
||||
@State private var saving = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
init(playlist: Playlist) {
|
||||
self.playlist = playlist
|
||||
_title = State(initialValue: playlist.title)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let list = tracks.value ?? []
|
||||
Group {
|
||||
if editing {
|
||||
// Native list editing: delete buttons on the left, grips on the right.
|
||||
List {
|
||||
header(list).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
ForEach(items) { t in
|
||||
TrackRow(track: t) {}.listRowBackground(p.bg)
|
||||
}
|
||||
.onDelete { idx in
|
||||
removed += idx.map { items[$0] }
|
||||
items.remove(atOffsets: idx)
|
||||
}
|
||||
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(p.bg)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
Page(title: title, large: false) {
|
||||
header(list)
|
||||
Loaded(state: tracks, retry: { reload += 1 }) { list in
|
||||
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t, extra: removeAction(t))) { g.playback.playTracks(list, start: i) }
|
||||
.listRow(inset: MARGIN + 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if !playlist.smart && !list.isEmpty {
|
||||
if saving { ProgressView() }
|
||||
else if editing { Button("Done") { commit(list) }.bold() }
|
||||
else { Button("Edit") { items = list; removed = []; editing = true } }
|
||||
}
|
||||
if !editing { MoreMenu(items: moreItems(list)) }
|
||||
}
|
||||
}
|
||||
.task(id: reload) {
|
||||
await LoadState.load($tracks) { try await g.plex.playlistTracks(playlist.ratingKey) }
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ list: [Track]) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: playlist.thumb, size: 230, corner: 10, placeholder: "music.note.list")
|
||||
Text(title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||
.padding(.horizontal, 32).padding(.top, 14)
|
||||
if !list.isEmpty {
|
||||
Text("\(songs(list.count)) · \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))").font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
if !editing {
|
||||
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) }).padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8).padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private func removeAction(_ t: Track) -> [SheetAction] {
|
||||
if playlist.smart { return [] }
|
||||
return [SheetAction("Remove from Playlist", icon: "minus.circle", destructive: true) {
|
||||
Task {
|
||||
if let id = t.playlistItemId { try? await g.plex.removeFromPlaylist(playlist.ratingKey, playlistItemId: id) }
|
||||
reload += 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
private func moreItems(_ list: [Track]) -> [SheetAction] {
|
||||
var extra: [SheetAction] = []
|
||||
let plex = g.plex
|
||||
let key = playlist.ratingKey
|
||||
if !playlist.smart {
|
||||
extra.append(SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Playlist", nil, initial: title, "Save") { name in
|
||||
Task { if (try? await plex.renamePlaylist(key, title: name)) != nil { title = name } }
|
||||
}
|
||||
})
|
||||
}
|
||||
extra.append(SheetAction("Delete Playlist", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Delete “\(title)”?", "This removes it from Plex for every app.", "Delete") {
|
||||
Task { if (try? await plex.deletePlaylist(key)) != nil { ui.pop() } }
|
||||
}
|
||||
})
|
||||
var pl = playlist
|
||||
pl.title = title
|
||||
pl.trackCount = list.count
|
||||
return ui.menu.playlist(pl, extra: extra)
|
||||
}
|
||||
|
||||
/// Sends removals, then replays the new order as "move after" calls against a simulated server list.
|
||||
private func commit(_ original: [Track]) {
|
||||
let target = items
|
||||
let gone = removed
|
||||
let plex = g.plex
|
||||
let key = playlist.ratingKey
|
||||
saving = true
|
||||
Task {
|
||||
do {
|
||||
for t in gone { if let id = t.playlistItemId { try await plex.removeFromPlaylist(key, playlistItemId: id) } }
|
||||
let goneIds = Set(gone.compactMap(\.playlistItemId))
|
||||
var sim = original.filter { !goneIds.contains($0.playlistItemId ?? -1) }
|
||||
for (i, t) in target.enumerated() {
|
||||
guard let id = t.playlistItemId, i >= sim.count || sim[i].playlistItemId != id else { continue }
|
||||
try await plex.movePlaylistItem(key, playlistItemId: id, afterItemId: i == 0 ? nil : target[i - 1].playlistItemId)
|
||||
sim.removeAll { $0.playlistItemId == id }
|
||||
sim.insert(t, at: min(i, sim.count))
|
||||
}
|
||||
} catch {
|
||||
ui.overlays.hud("Couldn't save: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
reload += 1
|
||||
saving = false
|
||||
editing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- search ---------------------------------------------------------------------------------
|
||||
|
||||
struct SearchScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var query = ""
|
||||
@State private var results: LoadState<SearchResults>?
|
||||
@State private var retry = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
Page(title: "Search") {
|
||||
if let results {
|
||||
Loaded(state: results, retry: { retry += 1 }) { s in
|
||||
if s.isEmpty { EmptyState(icon: "magnifyingglass", title: "No Results", message: "Nothing matches “\(q)”.") }
|
||||
if !s.artists.isEmpty {
|
||||
SectionTitle(title: "Artists")
|
||||
ForEach(s.artists) { a in
|
||||
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain).actionsMenu(ui.menu.artist(a)).listRow(inset: MARGIN + 58, trailing: 16)
|
||||
}
|
||||
}
|
||||
if !s.albums.isEmpty {
|
||||
SectionTitle(title: "Albums")
|
||||
AlbumGrid(albums: s.albums, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
if !s.tracks.isEmpty {
|
||||
SectionTitle(title: "Songs")
|
||||
ForEach(Array(s.tracks.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, subtitle: "\(t.artist) — \(t.album)", menu: ui.menu.track(t)) { g.playback.playTracks(s.tracks, start: i) }
|
||||
.listRow(inset: MARGIN + 60)
|
||||
}
|
||||
}
|
||||
if !s.playlists.isEmpty {
|
||||
SectionTitle(title: "Playlists")
|
||||
ForEach(s.playlists) { pl in
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "magnifyingglass", title: "Search Your Library", message: "Find artists, albums, songs and playlists on your Plex server.")
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Albums, Songs")
|
||||
.autocorrectionDisabled()
|
||||
.task(id: "\(q)|\(retry)") {
|
||||
guard q.count >= 2 else { results = nil; return }
|
||||
try? await Task.sleep(nanoseconds: 280_000_000)
|
||||
if Task.isCancelled { return }
|
||||
results = .loading
|
||||
do { results = .ok(try await g.plex.search(q)) }
|
||||
catch is CancellationError {}
|
||||
catch { results = .err(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
||||
private struct NavCell: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var icon: String?
|
||||
var iconBg: UInt32 = 0x8E8E93
|
||||
let route: Route
|
||||
var body: some View {
|
||||
NavigationLink(value: route) { ValueCell(title: title, value: value, icon: icon, iconBg: iconBg) }.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleCell: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let iconBg: UInt32
|
||||
let isOn: Binding<Bool>
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Toggle(isOn: isOn) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: iconBg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row in the accent (or red), optionally centred.
|
||||
private struct ActionCell: View {
|
||||
let title: String
|
||||
var color: Color?
|
||||
var centered = false
|
||||
var enabled = true
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title).foregroundStyle(enabled ? color ?? p.accent : p.label3)
|
||||
.frame(maxWidth: .infinity, alignment: centered ? .center : .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Label on the left, an inline text field on the right.
|
||||
private struct FieldCell: View {
|
||||
let label: String
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
var secret = false
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(p.label).frame(width: 96, alignment: .leading)
|
||||
Group {
|
||||
if secret { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text).keyboardType(.URL) }
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundStyle(p.label2)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private struct Header: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
}
|
||||
|
||||
private struct Footer: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
}
|
||||
|
||||
private func clean(_ url: String) -> String {
|
||||
var u = url.trimmingCharacters(in: .whitespaces)
|
||||
while u.hasSuffix("/") { u.removeLast() }
|
||||
return u
|
||||
}
|
||||
|
||||
// ---- settings -------------------------------------------------------------------------------
|
||||
|
||||
struct SettingsScreen: View {
|
||||
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.
|
||||
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") }
|
||||
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)
|
||||
} 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") }
|
||||
Section {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plex -----------------------------------------------------------------------------------
|
||||
|
||||
struct PlexAccountScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@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
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
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) }
|
||||
}
|
||||
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 {
|
||||
s.musicSection = sec.key
|
||||
s.musicSectionTitle = sec.title
|
||||
} 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 sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
}
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity).themedRow()
|
||||
}
|
||||
} header: { Header(text: "Music Library") }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Plex")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: s.plexToken) {
|
||||
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
|
||||
}
|
||||
// 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 {
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
await finish(t)
|
||||
ui.overlays.hud("Signed In")
|
||||
return
|
||||
}
|
||||
}
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
pinError = "The code expired. Try again."
|
||||
}
|
||||
}
|
||||
|
||||
private func test() {
|
||||
let s = g.settings
|
||||
testing = true
|
||||
Task {
|
||||
let ok = await g.plex.probe(url, token: s.plexToken)
|
||||
testing = false
|
||||
if ok {
|
||||
s.plexUrl = clean(url)
|
||||
ui.overlays.hud("Connected")
|
||||
} else {
|
||||
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func newPin() {
|
||||
pinError = nil
|
||||
Task {
|
||||
do {
|
||||
let pin = try await g.plex.createPin()
|
||||
pinCode = pin.code
|
||||
pinId = pin.id
|
||||
} catch {
|
||||
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useToken() {
|
||||
let t = manual.trimmingCharacters(in: .whitespaces)
|
||||
Task {
|
||||
if await g.plex.probe(url, token: t) { await finish(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" }
|
||||
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
|
||||
}
|
||||
await g.stems.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stem server ----------------------------------------------------------------------------
|
||||
|
||||
struct StemServerScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var url = AppGraph.shared.settings.stemUrl
|
||||
@State private var token = AppGraph.shared.settings.stemToken
|
||||
@State private var busy = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let status = g.stems.status
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:8765")
|
||||
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.")
|
||||
}
|
||||
Section {
|
||||
ValueCell(title: "Connection", value: {
|
||||
switch status {
|
||||
case .online: "Online"
|
||||
case .offline: "Offline"
|
||||
case .unknown: "Checking"
|
||||
}
|
||||
}()).themedRow()
|
||||
ValueCell(title: "Songs with Stems", value: {
|
||||
switch status {
|
||||
case .online(let n, _): "\(n)"
|
||||
case .offline(_, let n): "\(n) (cached)"
|
||||
case .unknown: "—"
|
||||
}
|
||||
}()).themedRow()
|
||||
ValueCell(title: "Opus Transcoding", value: {
|
||||
if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" }
|
||||
return "—"
|
||||
}()).themedRow()
|
||||
if case .offline(let reason, _) = status {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Error").foregroundStyle(p.label)
|
||||
Text(reason).font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Status") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Stem Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
let s = g.settings
|
||||
s.stemUrl = clean(url)
|
||||
s.stemToken = token.trimmingCharacters(in: .whitespaces)
|
||||
busy = true
|
||||
Task {
|
||||
await g.stems.refresh(force: true)
|
||||
busy = false
|
||||
let ok = g.stems.status.isOnline
|
||||
ui.overlays.hud(ok ? "Connected" : "Couldn't connect", ok ? "checkmark" : "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- quality --------------------------------------------------------------------------------
|
||||
|
||||
/// A row with a title, grey detail and a checkmark when chosen.
|
||||
private struct CheckCell: View {
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
let checked: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).foregroundStyle(p.label)
|
||||
if let subtitle { Text(subtitle).font(.footnote).foregroundStyle(p.label2) }
|
||||
}
|
||||
Spacer()
|
||||
if checked { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
List {
|
||||
Section {
|
||||
ForEach(StemQuality.allCases) { q in
|
||||
CheckCell(title: q.label, subtitle: q.detail, checked: s.stemQuality == q) { s.stemQuality = q }
|
||||
}
|
||||
} footer: {
|
||||
Footer(text: "For streaming and new downloads. Opus is made once on the server and kept there; 256 kbps is about an eighth of the FLAC size.")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Stem Quality")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- storage --------------------------------------------------------------------------------
|
||||
|
||||
struct StorageScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
private let limits = [2, 5, 10, 20, 50, 100]
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let usage = g.cache.usage
|
||||
List {
|
||||
Section {
|
||||
Button { ui.selectTab(.Downloads) } label: {
|
||||
HStack {
|
||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
ValueCell(title: "Streaming Cache", value: gb(max(0, usage.cachedBytes - usage.pinnedBytes))).themedRow()
|
||||
} header: { Header(text: "On This Phone") }
|
||||
Section {
|
||||
Picker("Limit", selection: $s.cacheLimitGb) {
|
||||
ForEach(limits, id: \.self) { Text("\($0) GB").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Streaming Cache Limit") } footer: {
|
||||
Footer(text: "Streamed songs stay for replay and offline use, oldest first out. Downloads don't count.")
|
||||
}
|
||||
Section {
|
||||
ActionCell(title: "Clear Streaming Cache", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Clear Cache?", "Downloads are kept.", "Clear") { g.cache.clearUnpinned() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Storage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- appearance -----------------------------------------------------------------------------
|
||||
|
||||
struct AppearanceScreen: View {
|
||||
@Environment(\.palette) private var c
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
ForEach(ThemeMode.allCases) { m in
|
||||
let p = Palette.of(m, s.accent)
|
||||
let on = m == s.theme
|
||||
Button { s.theme = m } label: {
|
||||
VStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Capsule().fill(p.label).frame(width: 40, height: 6)
|
||||
ForEach(0..<3, id: \.self) { _ in RoundedRectangle(cornerRadius: 4).fill(p.cell).frame(height: 18) }
|
||||
Capsule().fill(p.accent).frame(width: 24, height: 6)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(8)
|
||||
.frame(width: 78, height: 140)
|
||||
.background(p.groupedBg, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(on ? c.accent : c.separator, lineWidth: on ? 3 : 1))
|
||||
Text(m.label).font(.subheadline.weight(on ? .semibold : .regular)).foregroundStyle(c.label)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: { Header(text: "Theme") } footer: { Footer(text: "Black is true black for OLED. No gradients anywhere, by design.") }
|
||||
Section {
|
||||
HStack {
|
||||
ForEach(Accent.allCases) { a in
|
||||
Button { s.accent = a } label: {
|
||||
Circle().fill(Color(hex: c.dark ? a.dark : a.light)).frame(width: 32, height: 32)
|
||||
.overlay(Circle().stroke(a == s.accent ? c.label : .clear, lineWidth: 3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(a.label)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: { Header(text: "Accent") }
|
||||
Section {
|
||||
Toggle(isOn: $s.albumColorNowPlaying) { Text("Album Colour Background").foregroundStyle(c.label) }
|
||||
.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.") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Appearance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, alpha: Double = 1) {
|
||||
self.init(.sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255, opacity: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
/// The three themes' colours. Black is true black for OLED; no gradients anywhere.
|
||||
struct Palette {
|
||||
var dark: Bool
|
||||
var bg: Color
|
||||
var groupedBg: Color
|
||||
var cell: Color
|
||||
var fill: Color
|
||||
var fillStrong: Color
|
||||
var label: Color
|
||||
var label2: Color
|
||||
var label3: Color
|
||||
var separator: Color
|
||||
var placeholder: Color
|
||||
var accent: Color
|
||||
var red: Color
|
||||
|
||||
static func of(_ mode: ThemeMode, _ accent: Accent) -> Palette {
|
||||
switch mode {
|
||||
case .Black:
|
||||
Palette(dark: true, bg: Color(hex: 0x000000), groupedBg: Color(hex: 0x000000), cell: Color(hex: 0x1C1C1E),
|
||||
fill: Color(hex: 0x1C1C1E), fillStrong: Color(hex: 0x3A3A3C), label: .white, label2: Color(hex: 0x8D8D93),
|
||||
label3: Color(hex: 0x48484A), separator: Color(hex: 0x2C2C2E), placeholder: Color(hex: 0x1C1C1E),
|
||||
accent: Color(hex: accent.dark), red: Color(hex: 0xFF453A))
|
||||
case .Dark:
|
||||
Palette(dark: true, bg: Color(hex: 0x1C1C1E), groupedBg: Color(hex: 0x1C1C1E), cell: Color(hex: 0x2C2C2E),
|
||||
fill: Color(hex: 0x2C2C2E), fillStrong: Color(hex: 0x48484A), label: .white, label2: Color(hex: 0x98989D),
|
||||
label3: Color(hex: 0x5A5A5E), separator: Color(hex: 0x3D3D40), placeholder: Color(hex: 0x3A3A3C),
|
||||
accent: Color(hex: accent.dark), red: Color(hex: 0xFF453A))
|
||||
case .White:
|
||||
Palette(dark: false, bg: Color(hex: 0xFFFFFF), groupedBg: Color(hex: 0xF2F2F7), cell: Color(hex: 0xFFFFFF),
|
||||
fill: Color(hex: 0xEFEFF0), fillStrong: Color(hex: 0xE3E3E8), label: .black, label2: Color(hex: 0x8A8A8E),
|
||||
label3: Color(hex: 0xC4C4C7), separator: Color(hex: 0xD8D8DC), placeholder: Color(hex: 0xE5E5EA),
|
||||
accent: Color(hex: accent.light), red: Color(hex: 0xFF3B30))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PaletteKey: EnvironmentKey {
|
||||
static let defaultValue = Palette.of(.Black, .Music)
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var palette: Palette {
|
||||
get { self[PaletteKey.self] }
|
||||
set { self[PaletteKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// A list on the theme's background instead of the system one.
|
||||
func themedList(grouped: Bool = true) -> some View { modifier(ThemedList(grouped: grouped)) }
|
||||
|
||||
/// Rows on the theme's cell colour.
|
||||
func themedRow(grouped: Bool = true) -> some View { modifier(ThemedRow(grouped: grouped)) }
|
||||
}
|
||||
|
||||
private struct ThemedList: ViewModifier {
|
||||
@Environment(\.palette) private var p
|
||||
let grouped: Bool
|
||||
func body(content: Content) -> some View {
|
||||
content.scrollContentBackground(.hidden).background(grouped ? p.groupedBg : p.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThemedRow: ViewModifier {
|
||||
@Environment(\.palette) private var p
|
||||
let grouped: Bool
|
||||
func body(content: Content) -> some View {
|
||||
content.listRowBackground(grouped ? p.cell : p.bg).listRowSeparatorTint(p.separator)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user