Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -6,26 +6,34 @@ struct LibraryHome: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var played: [Album] = []
|
||||
@State private var added: [Track] = []
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty
|
||||
let ready = s.libraryReady
|
||||
let today = Date()
|
||||
Page(title: "Library") {
|
||||
if !ready {
|
||||
EmptyState(icon: "server.rack", title: "Connect to Plex",
|
||||
message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
EmptyState(icon: "server.rack", title: "Connect a Server",
|
||||
message: "Add your Plex or Jellyfin server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
|
||||
ui.push(.Servers)
|
||||
}
|
||||
}
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs),
|
||||
]
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { i, r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) }
|
||||
if ready {
|
||||
if !played.isEmpty {
|
||||
SectionTitle(title: "Recent Plays")
|
||||
RecentShelf(albums: played)
|
||||
}
|
||||
if !added.isEmpty {
|
||||
SectionTitle(title: "Recently Added")
|
||||
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) }
|
||||
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "On This Day")
|
||||
@@ -52,10 +60,48 @@ struct LibraryHome: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "Stations")
|
||||
let stations: [(String, String, () async throws -> [Track])] = [
|
||||
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }),
|
||||
("square.stack", "Random Album Radio", {
|
||||
var out: [Track] = []
|
||||
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) }
|
||||
return out
|
||||
}),
|
||||
]
|
||||
ForEach(stations, id: \.1) { st in
|
||||
LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) }
|
||||
}
|
||||
}
|
||||
// Every tab except Settings (and this one).
|
||||
SectionTitle(title: "Browse")
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres),
|
||||
("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio),
|
||||
]
|
||||
ForEach(rows, id: \.1) { r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) }
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.musicSection)|\(reload)") {
|
||||
.task(id: "\(s.libraryKey)|\(reload)") {
|
||||
guard ready else { return }
|
||||
async let rp = try? g.plex.recentlyPlayed(count: 24)
|
||||
async let ra = try? g.plex.recentlyAddedTracks(count: 10)
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
played = Array((await rp ?? []).prefix(24))
|
||||
added = Array((await ra ?? []).prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a station: its songs play in the order given (albums stay whole).
|
||||
private func play(_ load: @escaping () async throws -> [Track]) {
|
||||
Task {
|
||||
do {
|
||||
let list = try await load()
|
||||
if list.isEmpty { ui.overlays.hud("Nothing to play", "music.note") } else { g.playback.playTracks(list) }
|
||||
} catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +133,35 @@ private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday
|
||||
return (onDay, week)
|
||||
}
|
||||
|
||||
/// Recent plays as a two-row sideways shelf of small tiles, like Plexamp's, in iOS dress.
|
||||
private struct RecentShelf: View {
|
||||
let albums: [Album]
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private let size: CGFloat = 112
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: Array(repeating: GridItem(.fixed(size + 40), spacing: 14), count: albums.count > 4 ? 2 : 1), spacing: 12) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: size, corner: 8, placeholder: "square.stack")
|
||||
Text(a.title).font(.footnote.weight(.medium)).foregroundStyle(p.label).lineLimit(1).padding(.top, 5)
|
||||
Text(a.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
.frame(width: size, alignment: .topLeading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.push(.AlbumPage(a)) }
|
||||
.actionsMenu(ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@@ -236,6 +311,7 @@ struct SortMenu: View {
|
||||
} }
|
||||
Section {
|
||||
Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly)
|
||||
Toggle("No Stems", systemImage: "waveform.slash", isOn: Bindable(AppGraph.shared.settings).noStems)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
@@ -248,13 +324,15 @@ struct SortMenu: View {
|
||||
@MainActor
|
||||
enum StemsOnly {
|
||||
private static var g: AppGraph { .shared }
|
||||
static var on: Bool { g.settings.stemsOnly && g.stems.index.size > 0 }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { g.stems.find($0) != nil } : list }
|
||||
static var on: Bool { (g.settings.stemsOnly || g.settings.noStems) && g.stems.index.size > 0 }
|
||||
/// With No Stems the filter keeps what Stems Only would drop.
|
||||
private static var want: Bool { !g.settings.noStems }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { (g.stems.find($0) != nil) == want } : list }
|
||||
static func albums(_ list: [Album]) -> [Album] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
static func artists(_ list: [Artist]) -> [Artist] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +371,7 @@ struct ArtistsScreen: View {
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -352,17 +430,24 @@ struct AlbumsScreen: View {
|
||||
private let sort = SortState("albums", ALBUM_SORTS)
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
/// Pinch out for fewer, bigger albums; pinch in for more.
|
||||
@AppStorage("albumColumns") private var columns = 2
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Albums") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, columns: columns, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(MagnifyGesture().onEnded { v in
|
||||
let n = v.magnification < 0.85 ? min(columns + 1, 5) : v.magnification > 1.15 ? max(columns - 1, 2) : columns
|
||||
if n != columns { withAnimation(.spring(duration: 0.35)) { columns = n } }
|
||||
})
|
||||
.sensoryFeedback(.selection, trigger: columns)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -415,7 +500,7 @@ struct SongsScreen: View {
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") {
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
|
||||
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
|
||||
loadMore()
|
||||
}
|
||||
@@ -464,7 +549,7 @@ struct AlbumScreen: View {
|
||||
var body: some View {
|
||||
let a = detail ?? album
|
||||
let list = tracks.value ?? []
|
||||
let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true }
|
||||
let dl = g.downloads.group(.album, a.ratingKey)
|
||||
Page(title: a.title, large: false) {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
|
||||
@@ -503,12 +588,13 @@ struct AlbumScreen: View {
|
||||
if !list.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if allDownloaded {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) }
|
||||
if let dl {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(dl.id) }
|
||||
} else {
|
||||
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
g.downloads.download(DownloadGroup(kind: .album, key: a.ratingKey, title: a.title, subtitle: a.artist, thumb: a.thumb), list)
|
||||
ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
}
|
||||
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
|
||||
} label: { Image(systemName: dl != nil ? "checkmark.circle" : "arrow.down.circle") }
|
||||
MoreMenu(items: ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
@@ -551,137 +637,128 @@ struct ArtistScreen: View {
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------
|
||||
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Name"]
|
||||
|
||||
/// Downloaded songs gathered back into their albums.
|
||||
private struct DownloadedAlbum {
|
||||
let album: Album
|
||||
/// A downloaded playlist, album or artist with its songs' download state.
|
||||
private struct DownloadedGroup {
|
||||
let group: DownloadGroup
|
||||
let records: [DownloadRecord]
|
||||
let released: String?
|
||||
var added: Int64 { records.map(\.added).max() ?? 0 }
|
||||
var done: Int { records.filter(\.done).count }
|
||||
var failed: Int { records.filter { $0.error != nil }.count }
|
||||
var complete: Bool { done == records.count }
|
||||
var tracks: [Track] {
|
||||
records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) }
|
||||
}
|
||||
}
|
||||
|
||||
private func artistSortKey(_ s: String) -> String {
|
||||
let t = s.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t
|
||||
/// In the group's own order (playlist order, album track order).
|
||||
var tracks: [Track] { records.filter(\.done).map(\.track) }
|
||||
/// Shown through [AlbumGrid], so the key carries the group id.
|
||||
var tile: Album { Album(ratingKey: group.id, title: group.title, artist: group.subtitle, thumb: group.thumb) }
|
||||
}
|
||||
|
||||
/// Downloads the way Plexamp does them: whole playlists, albums and artists, kept in step with the server.
|
||||
struct DownloadsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
// Release days come from the album list (served from the HTTP cache when the server's away).
|
||||
@State private var info: [String: Album] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let records = g.downloads.records
|
||||
let sort = g.settings.downloadSort
|
||||
let albums = group(records, sort)
|
||||
let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } }
|
||||
let d = g.downloads
|
||||
let records = d.records
|
||||
let sort = min(g.settings.downloadSort, DOWNLOAD_SORTS.count - 1)
|
||||
let all = d.groups.values.map { gr in DownloadedGroup(group: gr, records: gr.trackKeys.compactMap { records[$0] }) }
|
||||
.sorted { sort == 1 ? $0.group.title.lowercased() < $1.group.title.lowercased() : $0.group.added > $1.group.added }
|
||||
let byId = Dictionary(all.map { ($0.group.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let loose = d.looseRecords.map(\.track)
|
||||
let allDone = records.values.filter(\.done).map(\.track)
|
||||
let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
|
||||
Page(title: "Downloads") {
|
||||
if albums.isEmpty {
|
||||
if all.isEmpty && loose.isEmpty {
|
||||
EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded",
|
||||
message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.")
|
||||
message: "Hold any playlist, album or artist and choose Download to play it, stems and all, without your server.")
|
||||
} else {
|
||||
Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
|
||||
Text("\(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
|
||||
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) })
|
||||
.padding(.bottom, 14)
|
||||
AlbumGrid(albums: albums.map(\.album), subtitle: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return a.artist }
|
||||
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
|
||||
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
|
||||
if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" }
|
||||
return a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return [] }
|
||||
var extra: [SheetAction] = []
|
||||
if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if !d.complete {
|
||||
extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) {
|
||||
g.downloads.remove(d.records.filter { !$0.done }.map(\.track))
|
||||
})
|
||||
ForEach([("Playlists", [DownloadGroup.Kind.playlist, .stemPlaylist]), ("Albums", [.album]), ("Artists", [.artist])], id: \.0) { title, kinds in
|
||||
let list = all.filter { kinds.contains($0.group.kind) }
|
||||
if !list.isEmpty {
|
||||
SectionTitle(title: title)
|
||||
grid(list, byId)
|
||||
}
|
||||
return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra)
|
||||
}, open: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return }
|
||||
if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) }
|
||||
}) { a in
|
||||
if let d = byKey[a.ratingKey], !d.complete {
|
||||
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
|
||||
ZStack {
|
||||
Circle().fill(Color.black.opacity(0.55))
|
||||
if d.failed > 0 {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
|
||||
} else {
|
||||
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
if !loose.isEmpty {
|
||||
SectionTitle(title: "Other Songs")
|
||||
ForEach(Array(loose.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(loose, start: i) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !albums.isEmpty {
|
||||
if !all.isEmpty || !loose.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in
|
||||
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
|
||||
})
|
||||
}
|
||||
MoreMenu(items: moreItems(albums, pinned))
|
||||
MoreMenu(items: moreItems(all, pinned))
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: g.settings.musicSection) {
|
||||
guard g.plex.signedIn else { return }
|
||||
if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) }
|
||||
.task { await g.downloads.refresh() }
|
||||
}
|
||||
|
||||
private func grid(_ list: [DownloadedGroup], _ byId: [String: DownloadedGroup]) -> some View {
|
||||
AlbumGrid(albums: list.map(\.tile), subtitle: { a in
|
||||
guard let d = byId[a.ratingKey] else { return a.artist }
|
||||
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
|
||||
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
|
||||
return a.artist == "Artist" || a.artist.hasSuffix("Playlist") ? "\(a.artist) · \(songs(d.records.count))" : a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byId[a.ratingKey] else { return [] }
|
||||
var items = [
|
||||
SheetAction("Play", icon: "play.fill") { g.playback.playTracks(d.tracks) },
|
||||
SheetAction("Shuffle", icon: "shuffle") { g.playback.playTracks(d.tracks, shuffle: true) },
|
||||
]
|
||||
if d.failed > 0 { items.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
items.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove Download?", "“\(d.group.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(d.group.id) }
|
||||
})
|
||||
return items
|
||||
}, open: { a in
|
||||
guard let gr = byId[a.ratingKey]?.group else { return }
|
||||
switch gr.kind {
|
||||
case .album: ui.push(.AlbumPage(Album(ratingKey: gr.key, title: gr.title, artist: gr.subtitle, thumb: gr.thumb)))
|
||||
case .artist: ui.push(.ArtistPage(Artist(ratingKey: gr.key, title: gr.title, sortTitle: gr.title, thumb: gr.thumb)))
|
||||
case .playlist: ui.push(.PlaylistPage(Playlist(ratingKey: gr.key, title: gr.title, thumb: gr.thumb, trackCount: gr.trackKeys.count)))
|
||||
case .stemPlaylist: ui.push(.StemPlaylistPage(gr.key))
|
||||
}
|
||||
}) { a in
|
||||
if let d = byId[a.ratingKey], !d.complete, !d.records.isEmpty {
|
||||
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
|
||||
ZStack {
|
||||
Circle().fill(Color.black.opacity(0.55))
|
||||
if d.failed > 0 {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
|
||||
} else {
|
||||
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
|
||||
private func moreItems(_ all: [DownloadedGroup], _ pinned: String) -> [SheetAction] {
|
||||
var a: [SheetAction] = []
|
||||
if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if all.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") {
|
||||
for id in g.downloads.groups.keys { g.downloads.removeGroup(id) }
|
||||
g.downloads.remove(g.downloads.records.values.map(\.track))
|
||||
}
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] {
|
||||
let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in
|
||||
let t = rs[0].track
|
||||
let known = info[key]
|
||||
let artists = Set(rs.map(\.track.artist))
|
||||
let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album,
|
||||
artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
|
||||
return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init))
|
||||
}
|
||||
switch sort {
|
||||
case 1:
|
||||
return grouped.sorted {
|
||||
(artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) <
|
||||
(artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased())
|
||||
}
|
||||
case 2:
|
||||
return grouped.sorted { a, b in
|
||||
(a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased()
|
||||
}
|
||||
default:
|
||||
return grouped.sorted { $0.added > $1.added }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- genres -----------------------------------------------------------------------------------
|
||||
@@ -713,7 +790,7 @@ struct GenresScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
@@ -731,7 +808,7 @@ struct GenreScreen: View {
|
||||
AlbumGrid(albums: StemsOnly.albums(list.filter { $0.genre == genre }), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The buttons under the transport on Now Playing that the user can hide.
|
||||
enum NowPlayingButton: String, CaseIterable, Identifiable {
|
||||
case Mixer, Speed, Route, AirPlay, Karaoke
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .Mixer: "Stem Mixer"
|
||||
case .Speed: "Speed"
|
||||
case .Route: "Output & EQ"
|
||||
case .AirPlay: "AirPlay"
|
||||
case .Karaoke: "Karaoke"
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .Mixer: "slider.vertical.3"
|
||||
case .Speed: "speedometer"
|
||||
case .Route: "hifispeaker"
|
||||
case .AirPlay: "airplayaudio"
|
||||
case .Karaoke: "music.mic"
|
||||
}
|
||||
}
|
||||
static func hidden(_ s: String) -> Set<NowPlayingButton> { Set(s.split(separator: ",").compactMap { Self(rawValue: String($0)) }) }
|
||||
}
|
||||
|
||||
struct NowPlayingOptionsScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
List {
|
||||
Section {
|
||||
ForEach(NowPlayingButton.allCases) { b in
|
||||
Toggle(isOn: Binding(
|
||||
get: { !NowPlayingButton.hidden(s.hiddenNowPlaying).contains(b) },
|
||||
set: { on in
|
||||
var h = NowPlayingButton.hidden(s.hiddenNowPlaying)
|
||||
if on { h.remove(b) } else { h.insert(b) }
|
||||
s.hiddenNowPlaying = h.map(\.rawValue).sorted().joined(separator: ",")
|
||||
})) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: b.icon, bg: 0x5AC8FA)
|
||||
Text(b.label).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
|
||||
Section {
|
||||
Toggle(isOn: $s.lockScreenMix) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "lock.fill", bg: 0xFF9500)
|
||||
Text("Back Button Changes Mix").foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Text("Lock Screen").foregroundStyle(p.label2) } footer: {
|
||||
Text("While stems play, the back button on the lock screen, Control Center and headphones moves to the next mix instead of the previous song, like the Mix button in CarPlay. The mix name shows after the artist.").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Now Playing")
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperCell: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let icon: String
|
||||
let iconBg: UInt32
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: 0...50) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: iconBg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
Spacer(minLength: 8)
|
||||
Text(value == 0 ? "Off" : "\(value) songs").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ struct NowPlayingScreen: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var panel = NpPanel.Art
|
||||
@State private var tint: Color?
|
||||
/// How far the screen is being dragged down to close it.
|
||||
@State private var drop: CGFloat = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@@ -130,11 +132,22 @@ struct NowPlayingScreen: View {
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.overlay(alignment: .top) {
|
||||
// Stands in for the sheet's grabber: tap or drag down to close.
|
||||
Capsule().fill(c.label3).frame(width: 36, height: 5)
|
||||
.frame(width: 120, height: 20).contentShape(Rectangle())
|
||||
.onTapGesture { dismiss() }
|
||||
.gesture(screenDrag)
|
||||
}
|
||||
.offset(y: drop)
|
||||
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
|
||||
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
|
||||
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
|
||||
.environment(\.palette, c)
|
||||
.preferredColorScheme(c.dark ? .dark : .light)
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(.clear)
|
||||
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
|
||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||
@@ -145,10 +158,10 @@ struct NowPlayingScreen: View {
|
||||
@ViewBuilder
|
||||
private func panelView(_ track: Track, header: Bool) -> some View {
|
||||
switch panel {
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle())
|
||||
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
|
||||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
|
||||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
|
||||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,21 +202,31 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next, hold: pm.nextAlbum)
|
||||
Spacer()
|
||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
}
|
||||
.frame(height: 84)
|
||||
.padding(.top, 14)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
|
||||
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } }
|
||||
if !hidden.contains(.Speed) { SpeedMenu() }
|
||||
if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() }
|
||||
if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) }
|
||||
if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } }
|
||||
}
|
||||
.frame(height: 48)
|
||||
// Swipe up on the art (or here) for the queue.
|
||||
Button { show(.Queue) } label: {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
|
||||
if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) }
|
||||
}
|
||||
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.gesture(swipe(up: panel != .Queue))
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
@@ -223,6 +246,30 @@ struct NowPlayingScreen: View {
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
||||
}
|
||||
|
||||
/// Swipe up for the queue, down to go back to the art.
|
||||
private func swipe(up: Bool) -> some Gesture {
|
||||
DragGesture(minimumDistance: 20).onEnded { v in
|
||||
let dy = v.translation.height
|
||||
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
|
||||
}
|
||||
}
|
||||
|
||||
private var screenDrag: some Gesture {
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onChanged { v in
|
||||
let dy = v.translation.height
|
||||
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0
|
||||
}
|
||||
.onEnded { v in
|
||||
let dy = v.translation.height
|
||||
let vertical = abs(dy) > abs(v.translation.width)
|
||||
if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
|
||||
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
|
||||
}
|
||||
}
|
||||
|
||||
private func routeLabel(_ c: Palette) -> some View {
|
||||
let eq = g.eq
|
||||
let device = eq.device
|
||||
@@ -263,12 +310,32 @@ private struct TransportButton: View {
|
||||
let symbol: String
|
||||
let size: CGFloat
|
||||
let action: () -> Void
|
||||
var hold: (() -> Void)?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HoldButton(action: action, hold: hold) {
|
||||
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
/// A PressFade button that runs [hold] on a long press instead of the tap (hold Next to skip the album).
|
||||
struct HoldButton<Label: View>: View {
|
||||
let action: () -> Void
|
||||
var hold: (() -> Void)?
|
||||
@ViewBuilder let label: () -> Label
|
||||
@State private var held = false
|
||||
@State private var fired = 0
|
||||
var body: some View {
|
||||
Button { if held { held = false } else { action() } } label: { label() }
|
||||
.buttonStyle(PressFade())
|
||||
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
|
||||
.onChanged { _ in held = false }
|
||||
.onEnded { _ in
|
||||
guard let hold else { return }
|
||||
held = true; fired += 1; hold()
|
||||
})
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: fired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +679,32 @@ private struct MixerPanel: View {
|
||||
if let saved, !isSaved {
|
||||
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
|
||||
}
|
||||
ForEach(MixPresets.all) { preset in
|
||||
PresetChip(text: "New Preset", icon: "plus", selected: false) {
|
||||
ui.overlays.prompt("Save Preset", "Saves this mix for every song.", "Save", placeholder: "Preset name") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
CustomPreset.add(n, pm.mix)
|
||||
ui.overlays.hud("Preset saved", "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
let s = g.settings
|
||||
ForEach(MixPresets.bar(s)) { preset in
|
||||
if let target = preset.build(names) {
|
||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||
let on = MixPresets.normalized(target) == current
|
||||
let locked = s.lockedPreset == preset.id
|
||||
// Tapping the mix that's on goes back to the full song.
|
||||
PresetChip(text: preset.label, icon: locked ? "lock.fill" : nil, selected: on) { pm.applyMix(on ? [:] : target) }
|
||||
.contextMenu {
|
||||
if locked {
|
||||
Button("Unlock", systemImage: "lock.open") { s.lockedPreset = "" }
|
||||
} else {
|
||||
Button("Lock for All Songs", systemImage: "lock") { s.lockedPreset = preset.id; pm.applyMix(target) }
|
||||
}
|
||||
Button("Edit Stems", systemImage: "slider.horizontal.3") { ui.showInSettings(.StemEditor) }
|
||||
if preset.custom {
|
||||
Button("Delete Preset", systemImage: "trash", role: .destructive) { CustomPreset.remove(preset.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,6 +728,8 @@ private struct MixerPanel: View {
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
// Holds still when everything fits, so a drag down closes Now Playing instead.
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +794,7 @@ private struct StemFader: View {
|
||||
|
||||
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
|
||||
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
|
||||
private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
let dot: Color?
|
||||
let label: String
|
||||
let percent: Int
|
||||
@@ -728,7 +820,7 @@ private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleBox: View {
|
||||
struct ToggleBox: View {
|
||||
let text: String
|
||||
let on: Bool
|
||||
let onColor: Color
|
||||
@@ -799,6 +891,7 @@ struct FaderSlider: View {
|
||||
// ---- queue ----------------------------------------------------------------------------------
|
||||
|
||||
private struct QueuePanel: View {
|
||||
var close: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
@@ -820,7 +913,9 @@ private struct QueuePanel: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 6)
|
||||
// Hold a row to drag it; swipe for Play Next or Remove.
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height > 50 { close() } })
|
||||
// Drag the handles to reorder; tap to jump, long press for Play Next or Remove.
|
||||
List {
|
||||
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
|
||||
HStack(spacing: 12) {
|
||||
@@ -830,7 +925,6 @@ private struct QueuePanel: View {
|
||||
Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Image(systemName: "line.3.horizontal").foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 60)
|
||||
.contentShape(Rectangle())
|
||||
@@ -838,12 +932,9 @@ private struct QueuePanel: View {
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
.contextMenu {
|
||||
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
|
||||
.tint(.indigo)
|
||||
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
@@ -851,7 +942,7 @@ private struct QueuePanel: View {
|
||||
pm.move(first + f, first + (to > f ? to - 1 : to))
|
||||
}
|
||||
if upcoming.isEmpty {
|
||||
Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.")
|
||||
Text("Nothing queued. Use Play Next or Add to Queue on any song.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
|
||||
@@ -859,6 +950,7 @@ private struct QueuePanel: View {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,10 +1069,9 @@ private struct MiniTransport: View {
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
if next {
|
||||
Button(action: pm.next) {
|
||||
HoldButton(action: pm.next, hold: pm.nextAlbum) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ struct PlaylistsScreen: View {
|
||||
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(g.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
// /playlists may not take sort=random, so Random shuffles here.
|
||||
let random = sort.option.random
|
||||
await LoadState.load($lists) { try await random ? g.plex.playlists().shuffled() : g.plex.playlists(sort: sort.param) }
|
||||
|
||||
@@ -102,7 +102,7 @@ private struct FieldCell: View {
|
||||
private struct Header: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
var body: some View { Text(text).font(.subheadline.weight(.semibold)).foregroundStyle(p.label2).textCase(nil) }
|
||||
}
|
||||
|
||||
private struct Footer: View {
|
||||
@@ -117,57 +117,246 @@ private func clean(_ url: String) -> String {
|
||||
return u
|
||||
}
|
||||
|
||||
/// Four little faders in the stem colours: the Settings mark.
|
||||
private struct StemMark: View {
|
||||
var scale: CGFloat = 1
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 3 * scale) {
|
||||
ForEach(Array(zip(["vocals", "drums", "bass", "other"], [18.0, 26, 14, 22])), id: \.0) { name, h in
|
||||
Capsule().fill(Color(hex: Stems.color(name))).frame(width: 5 * scale, height: h * scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One stop on the way from the servers to the phone. The rail runs through the dot and
|
||||
/// joins the rows above and below, so the three read as one path.
|
||||
private struct PathStop: View {
|
||||
let title: String
|
||||
let detail: String
|
||||
let color: UInt32
|
||||
let on: Bool
|
||||
var first = false
|
||||
var last = false
|
||||
let route: Route
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
NavigationLink(value: route) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(first ? Color.clear : p.separator).frame(width: 2)
|
||||
Rectangle().fill(last ? Color.clear : p.separator).frame(width: 2)
|
||||
}
|
||||
Circle().fill(on ? Color(hex: color) : p.cell)
|
||||
.overlay(Circle().strokeBorder(on ? Color.clear : p.label3, lineWidth: 2))
|
||||
.frame(width: 13, height: 13)
|
||||
}
|
||||
.frame(width: 30)
|
||||
.frame(maxHeight: .infinity)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.body.weight(.semibold)).foregroundStyle(p.label)
|
||||
Text(detail).font(.subheadline).foregroundStyle(on ? p.label2 : p.label3).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 64)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private let appVersion: String = {
|
||||
let info = Bundle.main.infoDictionary
|
||||
return "\(info?["CFBundleShortVersionString"] as? String ?? "1.1") (\(info?["CFBundleVersion"] as? String ?? "1"))"
|
||||
}()
|
||||
|
||||
// ---- settings -------------------------------------------------------------------------------
|
||||
|
||||
struct SettingsScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let status = g.stems.status
|
||||
List {
|
||||
// Everything on one screen: no footers but the last, one line each.
|
||||
// The path music takes: servers, then stems, then this phone.
|
||||
Section {
|
||||
NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle),
|
||||
icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount)
|
||||
NavCell(title: "Stem Server", value: {
|
||||
switch status {
|
||||
case .online(let n, _): "\(n) songs"
|
||||
case .offline: "Offline"
|
||||
case .unknown: "…"
|
||||
}
|
||||
}(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer)
|
||||
} header: { Header(text: "Servers") }
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
StemMark()
|
||||
Text("Stemify").font(.system(size: 30, weight: .heavy, design: .rounded)).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text(appVersion).font(.footnote.monospacedDigit()).foregroundStyle(p.label3).padding(.bottom, 4)
|
||||
}
|
||||
.padding(.top, 14).padding(.bottom, 6)
|
||||
.listRowSeparator(.hidden)
|
||||
.themedRow()
|
||||
PathStop(title: "Music Servers", detail: musicDetail(s), color: Stems.color("vocals"), on: s.libraryReady, first: true, route: .Servers)
|
||||
PathStop(title: "Stem Server", detail: stemDetail(status), color: Stems.color("drums"), on: status.isOnline, route: .StemServer)
|
||||
PathStop(title: "This \(UIDevice.current.model)", detail: "\(gb(g.cache.usage.cachedBytes)) cached" + (s.downloadsWifiOnly ? ", downloads on Wi-Fi" : ""),
|
||||
color: Stems.color("bass"), on: true, last: true, route: .Storage)
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Listening") }
|
||||
Section {
|
||||
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
} header: { Header(text: "Storage") }
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} header: { Header(text: "Sound and look") }
|
||||
Section {
|
||||
StepperCell(title: "Cache Ahead", value: $s.precacheCount, icon: "arrow.down.to.line", iconBg: 0xFF9500)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "wifi", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
BackupCells()
|
||||
} header: { Header(text: "Backup") } footer: {
|
||||
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
|
||||
} header: { Header(text: "On this phone") } footer: {
|
||||
Footer(text: "Export saves your stem playlists, their mixes and your sound settings to a file. Server addresses and sign-ins stay out of it.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "About", value: nil, icon: "info.circle.fill", iconBg: 0x8E8E93, route: .About)
|
||||
}
|
||||
Section {
|
||||
DebugCells()
|
||||
} header: { Header(text: "Debug") } footer: {
|
||||
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
|
||||
} header: { Header(text: "Troubleshooting") } footer: {
|
||||
Footer(text: "Verbose logging adds playback and network detail. Sign-in tokens are stripped from the log.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
ToggleCell(title: "Only Show Songs With Stems", icon: "waveform", iconBg: 0xAF52DE, isOn: $s.stemsOnly)
|
||||
} header: { Header(text: "Display") } footer: {
|
||||
Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz")
|
||||
VStack(spacing: 8) {
|
||||
StemMark(scale: 0.6).opacity(0.8)
|
||||
Text("Stemify \(appVersion)").font(.footnote.weight(.medium)).foregroundStyle(p.label2)
|
||||
Text("Made by Chris Nutter and Kevin Viers").font(.footnote).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(22)
|
||||
.themedList()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func musicDetail(_ s: Settings) -> String {
|
||||
let ready = s.servers.filter(\.ready)
|
||||
if s.servers.isEmpty { return "Add a Plex or Jellyfin server" }
|
||||
if ready.isEmpty { return "No libraries switched on" }
|
||||
return ready.map(\.label).joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func stemDetail(_ st: StemServerStatus) -> String {
|
||||
switch st {
|
||||
case .online(let n, _): "\(n.formatted()) songs with stems"
|
||||
case .offline(_, let cached): cached > 0 ? "Can't reach it, \(cached.formatted()) songs saved" : "Can't reach it"
|
||||
case .unknown: "Checking…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- about ----------------------------------------------------------------------------------
|
||||
|
||||
private struct Maintainer: Identifiable {
|
||||
let name: String
|
||||
let role: String
|
||||
let avatar: String
|
||||
let color: UInt32
|
||||
let site: (title: String, icon: String, value: String, url: String)
|
||||
let coffee: String?
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
private let maintainers = [
|
||||
Maintainer(name: "Chris Nutter", role: "Stemify WebUI and the iOS app", avatar: "AvatarChris", color: Stems.color("vocals"),
|
||||
site: ("Website", "globe", "iamchrisama.com", "https://iamchrisama.com"),
|
||||
coffee: "https://buymeacoffee.com/cdnutter"),
|
||||
Maintainer(name: "Kevin Viers", role: "stemd and the Android app", avatar: "AvatarKevin", color: Stems.color("drums"),
|
||||
site: ("GitHub", "chevron.left.forwardslash.chevron.right", "kevinViers", "https://github.com/kevinViers/"),
|
||||
coffee: nil),
|
||||
]
|
||||
|
||||
/// A maintainer's picture in a ring of their stem colour, their name and what they look after.
|
||||
private struct MaintainerCard: View {
|
||||
let m: Maintainer
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(m.avatar).resizable().interpolation(.high).aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(Circle())
|
||||
.padding(3)
|
||||
.overlay(Circle().strokeBorder(Color(hex: m.color), lineWidth: 2.5))
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(m.name).font(.title3.weight(.bold)).foregroundStyle(p.label)
|
||||
Text(m.role).font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.accessibilityElement(children: .combine)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 10) {
|
||||
StemMark(scale: 1.5)
|
||||
Text("Stemify").font(.system(size: 34, weight: .heavy, design: .rounded)).foregroundStyle(p.label)
|
||||
Text("Version \(appVersion)").font(.footnote.monospacedDigit()).foregroundStyle(p.label3)
|
||||
Text("Your Plex and Jellyfin music, split into stems you can mix.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ForEach(maintainers) { m in
|
||||
Section {
|
||||
MaintainerCard(m: m)
|
||||
Link(destination: URL(string: m.site.url)!) {
|
||||
ValueCell(title: m.site.title, value: m.site.value, icon: m.site.icon, iconBg: 0x007AFF)
|
||||
}
|
||||
.themedRow()
|
||||
if let coffee = m.coffee {
|
||||
Link(destination: URL(string: coffee)!) {
|
||||
ValueCell(title: "Buy Me a Coffee", value: nil, icon: "cup.and.saucer.fill", iconBg: 0xFF9500)
|
||||
}
|
||||
.themedRow()
|
||||
} else {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "cup.and.saucer.fill", bg: 0x8E8E93)
|
||||
Text("Buy Me a Coffee").foregroundStyle(p.label3)
|
||||
Spacer(minLength: 8)
|
||||
Text("Coming soon").foregroundStyle(p.label3)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
} footer: {
|
||||
Footer(text: "Stemify is free. A coffee goes straight to the person you pick.")
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(22)
|
||||
.themedList()
|
||||
.navigationTitle("About")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,103 +396,109 @@ struct BackupCells: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plex -----------------------------------------------------------------------------------
|
||||
// ---- music servers --------------------------------------------------------------------------
|
||||
|
||||
struct PlexAccountScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
/// Said on both server screens: stems are found by file path, so libraries have to line up.
|
||||
private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all."
|
||||
|
||||
struct ServersScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var url = AppGraph.shared.settings.plexUrl
|
||||
@State private var manual = ""
|
||||
@State private var pinCode: String?
|
||||
@State private var pinId: Int64 = 0
|
||||
@State private var pinError: String?
|
||||
@State private var sections: [MusicSection]?
|
||||
@State private var testing = false
|
||||
@State private var libs: [String: [MusicSection]] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let list = g.settings.servers
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400")
|
||||
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
||||
} header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") }
|
||||
if s.plexToken.isEmpty {
|
||||
Section {
|
||||
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
||||
if let pinCode {
|
||||
VStack(spacing: 0) {
|
||||
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
||||
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: {
|
||||
if let f = pinError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
||||
if list.isEmpty { Text("No servers yet").foregroundStyle(p.label2).themedRow() }
|
||||
ForEach(list) { s in
|
||||
NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"),
|
||||
icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id))
|
||||
}
|
||||
} header: { Header(text: "Music Servers") } footer: {
|
||||
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
|
||||
}
|
||||
ForEach(list.filter(\.signedIn)) { s in
|
||||
Section {
|
||||
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { useToken() }
|
||||
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
||||
} else {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "person.fill", bg: 0xE5A00D)
|
||||
Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label)
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Header(text: "Account") }
|
||||
Section {
|
||||
if let list = sections {
|
||||
if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() }
|
||||
ForEach(list) { sec in
|
||||
Button {
|
||||
// Tapping adds or drops a library; the picked ones are pooled. One always stays.
|
||||
var keys = s.musicSection.split(separator: ",").map(String.init)
|
||||
if let i = keys.firstIndex(of: sec.key) { if keys.count > 1 { keys.remove(at: i) } } else { keys.append(sec.key) }
|
||||
s.musicSection = keys.joined(separator: ",")
|
||||
s.musicSectionTitle = keys.compactMap { k in list.first { $0.key == k }?.title }.joined(separator: " + ")
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sec.title).foregroundStyle(p.label)
|
||||
if !sec.locations.isEmpty {
|
||||
Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
if let secs = libs[s.id] {
|
||||
if secs.isEmpty { Text("No music libraries").foregroundStyle(p.label2).themedRow() }
|
||||
ForEach(secs) { sec in
|
||||
ToggleCell(title: sec.title, icon: "music.note.house", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3,
|
||||
isOn: Binding(get: { s.sections.contains(sec.key) }, set: { on in set(s, sec, on, secs) }))
|
||||
}
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity).themedRow()
|
||||
}
|
||||
} header: { Header(text: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one library.") }
|
||||
} header: { Header(text: s.label + " Libraries") }
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex"))
|
||||
NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin"))
|
||||
} header: { Header(text: "Add") } footer: { Footer(text: matchNote) }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Music Servers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
for s in list where s.signedIn && libs[s.id] == nil {
|
||||
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns one library of a server on or off. All off keeps the sign-in.
|
||||
private func set(_ s: MediaServer, _ sec: MusicSection, _ on: Bool, _ secs: [MusicSection]) {
|
||||
var srv = s
|
||||
if on { if !srv.sections.contains(sec.key) { srv.sections.append(sec.key) } } else { srv.sections.removeAll { $0 == sec.key } }
|
||||
srv.sectionTitles = srv.sections.compactMap { k in secs.first { $0.key == k }?.title }.joined(separator: " + ")
|
||||
g.settings.save(srv)
|
||||
}
|
||||
}
|
||||
|
||||
/// One Plex or Jellyfin server: address and sign-in. Libraries are switched on in Music Servers.
|
||||
struct ServerScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var draft: MediaServer
|
||||
@State private var manual = ""
|
||||
@State private var user = ""
|
||||
@State private var password = ""
|
||||
@State private var pinCode: String?
|
||||
@State private var pinId: Int64 = 0
|
||||
@State private var signError: String?
|
||||
@State private var testing = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
init(id: String) {
|
||||
let s = AppGraph.shared.settings
|
||||
if let saved = s.servers.first(where: { $0.id == id }) {
|
||||
_draft = State(initialValue: saved)
|
||||
} else {
|
||||
_draft = State(initialValue: MediaServer(id: s.nextServerId(), kind: id == "new-jellyfin" ? .jellyfin : .plex))
|
||||
}
|
||||
}
|
||||
|
||||
private var plex: Bool { draft.kind == .plex }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Name", text: $draft.name, placeholder: draft.kindLabel)
|
||||
FieldCell(label: "Address", text: $draft.url, placeholder: plex ? "http://192.168.86.2:32400" : "http://192.168.86.2:8096")
|
||||
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
||||
} header: { Header(text: draft.kindLabel + " Server") } footer: {
|
||||
Footer(text: "The server's LAN address (port \(plex ? "32400" : "8096")). Add each server or IP as its own entry.")
|
||||
}
|
||||
if !draft.signedIn {
|
||||
if plex { plexSignIn } else { jellyfinSignIn }
|
||||
} else {
|
||||
Section {
|
||||
ActionCell(title: "Sign Out", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") {
|
||||
s.plexToken = ""
|
||||
s.plexUser = ""
|
||||
s.musicSection = ""
|
||||
s.musicSectionTitle = ""
|
||||
sections = nil
|
||||
ActionCell(title: "Remove Server", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Remove \(draft.label)?", "Downloads stay on this phone.", "Remove") {
|
||||
g.settings.removeServer(draft.id)
|
||||
ui.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,81 +506,157 @@ struct PlexAccountScreen: View {
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Plex")
|
||||
.navigationTitle(draft.label)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: s.plexToken) {
|
||||
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
|
||||
}
|
||||
.onChange(of: draft.name) { persist() }
|
||||
// Poll plex.tv while a PIN is showing.
|
||||
.task(id: pinId) {
|
||||
guard pinId != 0 else { return }
|
||||
for _ in 0..<300 {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
if let t = (try? await g.plex.checkPin(pinId))?.authToken, !t.isEmpty {
|
||||
if let t = (try? await g.plex.plexTv().checkPin(pinId))?.authToken, !t.isEmpty {
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
await finish(t)
|
||||
ui.overlays.hud("Signed In")
|
||||
await finishPlex(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
pinError = "The code expired. Try again."
|
||||
signError = "The code expired. Try again."
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var plexSignIn: some View {
|
||||
Section {
|
||||
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
||||
if let pinCode {
|
||||
VStack(spacing: 0) {
|
||||
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
||||
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: {
|
||||
if let f = signError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
||||
}
|
||||
Section {
|
||||
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { usePlexToken() }
|
||||
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
||||
}
|
||||
|
||||
@ViewBuilder private var jellyfinSignIn: some View {
|
||||
Section {
|
||||
FieldCell(label: "Username", text: $user, placeholder: "Required")
|
||||
FieldCell(label: "Password", text: $password, placeholder: "Optional", secret: true)
|
||||
ActionCell(title: "Sign In", enabled: !user.trimmingCharacters(in: .whitespaces).isEmpty) {
|
||||
jellyfinSignIn { try await $0.login(user: user.trimmingCharacters(in: .whitespaces), password: password) }
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: { if let signError { Footer(text: signError) } }
|
||||
Section {
|
||||
FieldCell(label: "API Key", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use API Key", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) {
|
||||
jellyfinSignIn { try await $0.loginWithKey(manual.trimmingCharacters(in: .whitespaces)) }
|
||||
}
|
||||
} header: { Header(text: "Or Use an API Key") } footer: { Footer(text: "Dashboard → API Keys on the Jellyfin server. It signs in as the first admin user.") }
|
||||
}
|
||||
|
||||
/// Saves the draft once it's signed in; before that there's nothing worth keeping.
|
||||
private func persist() {
|
||||
if draft.signedIn { g.settings.save(draft) }
|
||||
}
|
||||
|
||||
private func test() {
|
||||
let s = g.settings
|
||||
testing = true
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
let ok = await g.plex.probe(url, token: s.plexToken)
|
||||
let ok = plex ? await g.plex.plexTv(draft.url).probe(draft.url, token: draft.token) : await g.plex.jellyfin(draft).probe()
|
||||
testing = false
|
||||
if ok {
|
||||
s.plexUrl = clean(url)
|
||||
persist()
|
||||
ui.overlays.hud("Connected")
|
||||
} else {
|
||||
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
|
||||
ui.overlays.hud(plex && !draft.signedIn ? "Sign in first" : "Couldn't reach \(draft.kindLabel) at that address", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func newPin() {
|
||||
pinError = nil
|
||||
signError = nil
|
||||
Task {
|
||||
do {
|
||||
let pin = try await g.plex.createPin()
|
||||
let pin = try await g.plex.plexTv().createPin()
|
||||
pinCode = pin.code
|
||||
pinId = pin.id
|
||||
} catch {
|
||||
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
signError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useToken() {
|
||||
private func usePlexToken() {
|
||||
let t = manual.trimmingCharacters(in: .whitespaces)
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
if await g.plex.probe(url, token: t) { await finish(t) }
|
||||
if await g.plex.plexTv(draft.url).probe(draft.url, token: t) { await finishPlex(t) }
|
||||
else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ token: String) async {
|
||||
let s = g.settings
|
||||
s.plexUrl = clean(url)
|
||||
s.plexToken = token
|
||||
s.plexUser = await {
|
||||
guard let u = try? await g.plex.user(token: token) else { return "Plex user" }
|
||||
private func finishPlex(_ token: String) async {
|
||||
draft.url = clean(draft.url)
|
||||
let tv = g.plex.plexTv(draft.url)
|
||||
draft.user = await {
|
||||
guard let u = try? await tv.user(token: token) else { return "Plex user" }
|
||||
return u.title ?? u.username ?? "Plex user"
|
||||
}()
|
||||
s.plexMachineId = (try? await g.plex.machineId()) ?? ""
|
||||
sections = try? await g.plex.musicSections()
|
||||
if let only = sections, only.count == 1 {
|
||||
s.musicSection = only[0].key
|
||||
s.musicSectionTitle = only[0].title
|
||||
draft.machineId = (try? await tv.machineId()) ?? ""
|
||||
draft.token = token
|
||||
await finish()
|
||||
}
|
||||
|
||||
private func jellyfinSignIn(_ f: @escaping (JellyfinClient) async throws -> JellyfinLogin) {
|
||||
signError = nil
|
||||
draft.url = clean(draft.url)
|
||||
Task {
|
||||
do {
|
||||
let l = try await f(g.plex.jellyfin(draft))
|
||||
draft.user = l.user
|
||||
draft.userId = l.userId
|
||||
draft.machineId = l.serverId
|
||||
draft.token = l.token
|
||||
password = ""
|
||||
await finish()
|
||||
} catch {
|
||||
signError = "Couldn't sign in: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed in: pick the only library if there's just one, and save. The rest are switched on
|
||||
/// in Music Servers.
|
||||
private func finish() async {
|
||||
manual = ""
|
||||
if let only = try? await g.plex.make(draft).musicSections(), only.count == 1 {
|
||||
draft.sections = [only[0].key]
|
||||
draft.sectionTitles = only[0].title
|
||||
}
|
||||
persist()
|
||||
ui.overlays.hud("Signed In")
|
||||
await g.stems.refresh()
|
||||
}
|
||||
}
|
||||
@@ -408,7 +679,7 @@ struct StemServerScreen: View {
|
||||
FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true)
|
||||
ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() }
|
||||
} header: { Header(text: "stemserve") } footer: {
|
||||
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.")
|
||||
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.\n\n" + matchNote)
|
||||
}
|
||||
Section {
|
||||
ValueCell(title: "Connection", value: {
|
||||
@@ -607,8 +878,16 @@ struct AppearanceScreen: View {
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||
Section {
|
||||
Toggle(isOn: $s.acapella) { Text("Spell It “Acapella”").foregroundStyle(c.label) }
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Mix Names") } footer: {
|
||||
Footer(text: "Shows the A Cappella mix as Acapella in the mixer, CarPlay, the lock screen and widgets.")
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
|
||||
NavigationLink(value: Route.NowPlayingOptions) { Text("Now Playing Buttons").foregroundStyle(c.label) }.themedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
@@ -622,11 +901,11 @@ struct AppearanceScreen: View {
|
||||
|
||||
struct TabBarScreen: View {
|
||||
@Environment(\.palette) private var c
|
||||
@State private var shown: [Tab] = Tab.layout(AppGraph.shared.settings.tabLayout)
|
||||
private func commit() { g.settings.tabLayout = Tab.encode(shown) }
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let shown = Tab.layout(s.tabLayout)
|
||||
let hidden = Tab.allCases.filter { !shown.contains($0) }
|
||||
List {
|
||||
Section {
|
||||
@@ -639,18 +918,20 @@ struct TabBarScreen: View {
|
||||
.deleteDisabled(t == .Settings)
|
||||
}
|
||||
.onMove { from, to in
|
||||
var l = shown
|
||||
l.move(fromOffsets: from, toOffset: to)
|
||||
s.tabLayout = Tab.encode(l)
|
||||
shown.move(fromOffsets: from, toOffset: to)
|
||||
commit()
|
||||
}
|
||||
.onDelete { idx in
|
||||
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
|
||||
let gone = Set(idx.map { shown[$0] }).subtracting([.Settings])
|
||||
shown.removeAll { gone.contains($0) }
|
||||
if shown.count < 2 { shown = Tab.defaultLayout }
|
||||
commit()
|
||||
}
|
||||
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
|
||||
if !hidden.isEmpty {
|
||||
Section {
|
||||
ForEach(hidden, id: \.self) { t in
|
||||
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
|
||||
Button { if shown.count < Tab.maxVisible { shown.append(t); commit() } } label: {
|
||||
HStack {
|
||||
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||
Spacer()
|
||||
@@ -663,7 +944,7 @@ struct TabBarScreen: View {
|
||||
} header: { Header(text: "Available") }
|
||||
}
|
||||
Section {
|
||||
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
|
||||
Button("Reset to default") { shown = Tab.defaultLayout; commit() }.themedRow()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The mixer bar's presets: order them, delete or add them, bring back deleted built-ins and unlock a locked one.
|
||||
/// This list is also the rotation for the Mix button in CarPlay, on the lock screen and in the widgets.
|
||||
struct StemEditorScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(Ui.self) private var ui
|
||||
/// The preset being made or edited; a blank one for New Preset.
|
||||
@State private var editing: CustomPreset?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let hidden = MixPresets.hidden(s)
|
||||
let order = MixPresets.ordered(s)
|
||||
let shown = order.filter { !hidden.contains($0.id) }
|
||||
let off = order.filter { hidden.contains($0.id) }
|
||||
let mine = shown.filter(\.custom).count
|
||||
List {
|
||||
if let l = MixPresets.locked(s) {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "lock.fill", bg: 0xFF9500)
|
||||
Text(l.label).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Button("Unlock") { s.lockedPreset = "" }
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Locked").foregroundStyle(p.label2) } footer: {
|
||||
Text("Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(shown) { pr in
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: pr.custom ? "person.fill" : "slider.horizontal.3", bg: pr.custom ? 0xAF52DE : 0x5AC8FA)
|
||||
Text(pr.label).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
if pr.custom { Image(systemName: "slider.vertical.3").foregroundStyle(p.label3) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if pr.custom { editing = CustomPreset.load(s).first { $0.name == pr.label } } }
|
||||
.themedRow()
|
||||
.deleteDisabled(pr == MixPresets.full)
|
||||
}
|
||||
.onMove { from, to in
|
||||
var l = shown
|
||||
l.move(fromOffsets: from, toOffset: to)
|
||||
MixPresets.setOrder(s, l + off)
|
||||
}
|
||||
.onDelete { rows in
|
||||
for pr in rows.map({ shown[$0] }) {
|
||||
if pr.custom { CustomPreset.remove(pr.label) } else { MixPresets.setHidden(s, pr.id, true) }
|
||||
if s.lockedPreset == pr.id { s.lockedPreset = "" }
|
||||
}
|
||||
}
|
||||
Button { editing = CustomPreset(name: "", mix: [:]) } label: { Label("New Preset", systemImage: "plus.circle.fill") }
|
||||
.themedRow()
|
||||
} header: { Text("Mixer Bar").foregroundStyle(p.label2) } footer: {
|
||||
Text("Drag to reorder and tap one of yours to change it. The Mix button in CarPlay, on the lock screen and in the widgets goes through this list in this order.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
Section {
|
||||
Button { MixPresets.restoreDefaults(s) } label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "arrow.counterclockwise", bg: 0x34C759)
|
||||
Text("Restore Deleted Presets").foregroundStyle(off.isEmpty ? p.label3 : p.label)
|
||||
Spacer()
|
||||
if !off.isEmpty { Text("\(off.count)").foregroundStyle(p.label2) }
|
||||
}
|
||||
}
|
||||
.disabled(off.isEmpty)
|
||||
.themedRow()
|
||||
Button {
|
||||
let what = mine == 1 ? "your preset" : "all \(mine) of your presets"
|
||||
ui.overlays.confirm("Delete \(what)?", "This can’t be undone. The built-in presets stay.", "Delete") {
|
||||
CustomPreset.removeAll()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "trash", bg: 0xFF3B30)
|
||||
Text("Delete All Custom Presets").foregroundStyle(mine == 0 ? p.label3 : p.red)
|
||||
}
|
||||
}
|
||||
.disabled(mine == 0)
|
||||
.themedRow()
|
||||
} footer: {
|
||||
Text(off.isEmpty ? "Every built-in preset is in the list." : "Brings back \(off.map(\.label).joined(separator: ", ")).")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle("Stem Editor")
|
||||
.sheet(item: $editing) { PresetSheet(original: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a preset on the same faders and M / S buttons as the Now Playing mixer, plus a name.
|
||||
private struct PresetSheet: View {
|
||||
let original: CustomPreset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.palette) private var p
|
||||
@State private var name = ""
|
||||
@State private var mix: [String: StemSetting] = [:]
|
||||
|
||||
var body: some View {
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
let anySolo = mix.values.contains { $0.solo }
|
||||
NavigationStack {
|
||||
List {
|
||||
Section { TextField("Preset name", text: $name).themedRow() }
|
||||
Section {
|
||||
ForEach(StemPlaylists.allStems, id: \.self) { st in
|
||||
let set = mix[st] ?? StemSetting()
|
||||
let silent = set.muted || (anySolo && !set.solo)
|
||||
let color = Color(hex: Stems.color(st))
|
||||
FaderRow(dot: silent ? p.label3 : color, label: Stems.label(st), percent: Int(stemTaper(set.level) * 100), dim: silent) {
|
||||
FaderSlider(value: set.level, fill: silent ? p.label3 : color, detent: StemSetting.unity) { mix[st, default: StemSetting()].level = $0 }
|
||||
} trailing: {
|
||||
ToggleBox(text: "M", on: set.muted, onColor: p.red) { mix[st, default: StemSetting()].muted.toggle() }
|
||||
ToggleBox(text: "S", on: set.solo, onColor: Color(hex: 0xFFCC00)) { mix[st, default: StemSetting()].solo.toggle() }
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Stems").foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
Button("Reset") { mix = [:] }.font(.footnote)
|
||||
}
|
||||
} footer: {
|
||||
Text("Stems a song doesn't have are skipped when the preset is applied.").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle(original.name.isEmpty ? "New Preset" : "Edit Preset")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let s = AppGraph.shared.settings
|
||||
let old = original.name
|
||||
let locked = !old.isEmpty && s.lockedPreset == "c:" + old
|
||||
if !old.isEmpty && old != n {
|
||||
CustomPreset.remove(old)
|
||||
s.presetOrder = s.presetOrder.split(separator: "\n").map { $0 == "c:" + old ? "c:" + n : String($0) }.joined(separator: "\n")
|
||||
}
|
||||
CustomPreset.add(n, mix)
|
||||
if locked { s.lockedPreset = "c:" + n }
|
||||
dismiss()
|
||||
}
|
||||
.disabled(n.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { name = original.name; mix = original.mix }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Saved internet radio stations. Tap to play, swipe to delete, long press to edit.
|
||||
struct WebRadioScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@State private var editing: WebStation?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let radio = g.radio
|
||||
let list = s.stations
|
||||
List {
|
||||
if let on = radio.station {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "antenna.radiowaves.left.and.right", bg: 0xFF2D55)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(on.name).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(radio.playing ? "Live" : "Paused").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer()
|
||||
Button { radio.toggle() } label: {
|
||||
Image(systemName: radio.playing ? "pause.fill" : "play.fill").font(.system(size: 20)).frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button { radio.stop() } label: {
|
||||
Image(systemName: "stop.fill").font(.system(size: 18)).frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Now Playing").foregroundStyle(p.label2) }
|
||||
}
|
||||
Section {
|
||||
ForEach(list) { st in
|
||||
Button { radio.play(st) } label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "dot.radiowaves.left.and.right", bg: 0x5856D6)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(st.name).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(st.url).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if radio.station?.id == st.id { PlayingBars(playing: radio.playing) }
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
.swipeActions(edge: .leading) { Button("Edit") { editing = st }.tint(.orange) }
|
||||
.contextMenu {
|
||||
Button { editing = st } label: { Label("Edit", systemImage: "pencil") }
|
||||
Button(role: .destructive) { remove(st) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
.onDelete { rows in rows.map { list[$0] }.forEach(remove) }
|
||||
Button { editing = WebStation(name: "", url: "") } label: { Label("Add Station", systemImage: "plus.circle.fill") }
|
||||
.themedRow()
|
||||
} header: { Text("Stations").foregroundStyle(p.label2) } footer: {
|
||||
Text("Any direct stream URL works (MP3, AAC or HLS). Playing a station pauses your queue, and playing a song stops the station.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Web Radio")
|
||||
.sheet(item: $editing) { st in StationSheet(station: st) }
|
||||
}
|
||||
|
||||
private func remove(_ st: WebStation) {
|
||||
if g.radio.station?.id == st.id { g.radio.stop() }
|
||||
g.settings.stations.removeAll { $0.id == st.id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Name and stream URL for a new or existing station.
|
||||
private struct StationSheet: View {
|
||||
let station: WebStation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
|
||||
var body: some View {
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
let u = url.trimmingCharacters(in: .whitespaces)
|
||||
let valid = !n.isEmpty && URL(string: u).map { ["http", "https"].contains($0.scheme?.lowercased() ?? "") } == true
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
TextField("Name", text: $name).themedRow()
|
||||
TextField("Stream URL", text: $url)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle(station.name.isEmpty ? "New Station" : "Edit Station")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let s = AppGraph.shared.settings
|
||||
var list = s.stations
|
||||
let st = WebStation(id: station.id, name: n, url: u)
|
||||
if let i = list.firstIndex(where: { $0.id == station.id }) { list[i] = st } else { list.append(st) }
|
||||
s.stations = list
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { name = station.name; url = station.url }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user