Web player overhaul; iOS random sort, stems-only filter, pooled libraries
Web: live Stem Now status that tracks every queued song (albums too), right-click menus with Stem Now, Songs and Genres pages, a Stems Only filter in place of the sidebar section, Random sort, several pooled Plex servers and libraries, themes plus custom CSS, a reorganised settings page that loads without a refresh, saved mixes, karaoke and synced lyrics, stem playlists and offline downloads. iOS: Random in every sort menu, a Stems Only toggle (sort menu and Settings), and several music libraries pooled from the signed-in server. Settings now persists karaoke and speed across launches. stemd writes a status file that stemserve serves at /api/queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48faa6682f
commit
7cdfbef160
16 files changed
+2006
-730
No files matched your search
@@ -103,6 +103,7 @@ final class Settings: @unchecked Sendable {
|
||||
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
||||
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
||||
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly",
|
||||
]
|
||||
|
||||
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
||||
@@ -189,6 +190,8 @@ final class Settings: @unchecked Sendable {
|
||||
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
|
||||
/// How far down Soft karaoke puts the vocals, in dB.
|
||||
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||
/// Library lists show only what has stems.
|
||||
var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false) } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var playbackSpeed: Double {
|
||||
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }
|
||||
|
||||
@@ -36,7 +36,8 @@ final class PlexClient: @unchecked Sendable {
|
||||
var signedIn: Bool { !settings.plexToken.isEmpty }
|
||||
private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private var token: String { settings.plexToken }
|
||||
private var section: String { settings.musicSection }
|
||||
/// The chosen music libraries; listings pool them, in the order they were picked.
|
||||
private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) }
|
||||
|
||||
private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
@@ -97,6 +98,33 @@ final class PlexClient: @unchecked Sendable {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Every section's listing, one after another.
|
||||
private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
for sec in sections { out += try await all("/library/sections/\(sec)/all", params) }
|
||||
return out
|
||||
}
|
||||
|
||||
/// A page of a listing that runs on through each section in turn.
|
||||
private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) {
|
||||
if sections.count == 1 {
|
||||
let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size)
|
||||
return (c.metadata, c.totalSize ?? c.size)
|
||||
}
|
||||
var out: [PlexItem] = []
|
||||
var offset = 0, total = 0
|
||||
for sec in sections {
|
||||
let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0)
|
||||
let count = n.totalSize ?? n.size
|
||||
let from = max(start + out.count - offset, 0)
|
||||
if out.count < size, from < count {
|
||||
out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata
|
||||
}
|
||||
offset += count; total += count
|
||||
}
|
||||
return (out, total)
|
||||
}
|
||||
|
||||
// ---- server -------------------------------------------------------------------
|
||||
|
||||
func machineId() async throws -> String {
|
||||
@@ -114,27 +142,36 @@ final class PlexClient: @unchecked Sendable {
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||
try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
try await get("/library/sections/\(section)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
var out: [Album] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// One page of every track. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
|
||||
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
||||
let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size)
|
||||
return (page.map { $0.toTrack() }, total)
|
||||
}
|
||||
|
||||
/// Random tracks from the whole library, for shuffling it without loading every page.
|
||||
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||
try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "random")], start: 0, size: count).metadata.map { $0.toTrack() }
|
||||
var out: [Track] = []
|
||||
for sec in sections {
|
||||
out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1)
|
||||
.metadata.map { $0.toTrack() }
|
||||
}
|
||||
return Array(out.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
@@ -148,7 +185,7 @@ final class PlexClient: @unchecked Sendable {
|
||||
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
|
||||
// albums (an artist with only "The Best of Kansas" came back empty).
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
|
||||
try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
@@ -161,7 +198,9 @@ final class PlexClient: @unchecked Sendable {
|
||||
|
||||
/// Title search inside the music section; used to give stem-only entries Plex metadata.
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
try await get("/library/sections/\(section)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() }
|
||||
var out: [Track] = []
|
||||
for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB.
|
||||
@@ -178,7 +217,7 @@ final class PlexClient: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", section)]).hubs
|
||||
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs
|
||||
func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] }
|
||||
return SearchResults(
|
||||
artists: hub("artist").map { $0.toArtist() },
|
||||
|
||||
@@ -121,9 +121,12 @@ struct SortOption {
|
||||
var up = "Ascending"
|
||||
var down = "Descending"
|
||||
|
||||
/// Random has no direction; picking it again reshuffles.
|
||||
var random: Bool { key == "random" }
|
||||
|
||||
/// Plex reverses a compound key by its first field.
|
||||
func param(_ reversed: Bool) -> String {
|
||||
guard desc != reversed else { return key }
|
||||
guard !random, desc != reversed else { return key }
|
||||
var fields = key.split(separator: ",").map(String.init)
|
||||
fields[0] += ":desc"
|
||||
return fields.joined(separator: ",")
|
||||
@@ -133,6 +136,7 @@ struct SortOption {
|
||||
private let AZ = (up: "A to Z", down: "Z to A")
|
||||
private let NEW = (up: "Oldest First", down: "Newest First")
|
||||
private let MOST = (up: "Fewest First", down: "Most First")
|
||||
private let RANDOM = SortOption(title: "Random", key: "random", up: "Shuffled", down: "Shuffled")
|
||||
|
||||
let ARTIST_SORTS = [
|
||||
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||
@@ -140,6 +144,7 @@ let ARTIST_SORTS = [
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
RANDOM,
|
||||
]
|
||||
|
||||
let ALBUM_SORTS = [
|
||||
@@ -151,6 +156,7 @@ let ALBUM_SORTS = [
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
RANDOM,
|
||||
]
|
||||
|
||||
let SONG_SORTS = [
|
||||
@@ -162,6 +168,7 @@ let SONG_SORTS = [
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||
RANDOM,
|
||||
]
|
||||
|
||||
let PLAYLIST_SORTS = [
|
||||
@@ -171,6 +178,7 @@ let PLAYLIST_SORTS = [
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||
RANDOM,
|
||||
]
|
||||
|
||||
/// A screen's sort choice, kept across launches under `sort.<name>`.
|
||||
@@ -188,6 +196,8 @@ struct SortState: DynamicProperty {
|
||||
var current: Int { options.indices.contains(index) ? index : 0 }
|
||||
var option: SortOption { options[current] }
|
||||
var param: String { option.param(reversed) }
|
||||
/// Changes whenever the list should reload, including a reshuffle of Random.
|
||||
var token: String { "\(param)|\(reversed)" }
|
||||
/// The default order (first option, its own direction), where a letter index makes sense.
|
||||
var isDefault: Bool { current == 0 && !reversed }
|
||||
|
||||
@@ -216,13 +226,16 @@ struct SortMenu: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if !sort.option.random { Section {
|
||||
Button { sort.setDescending(false) } label: {
|
||||
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
|
||||
}
|
||||
Button { sort.setDescending(true) } label: {
|
||||
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
|
||||
}
|
||||
} }
|
||||
Section {
|
||||
Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
@@ -230,6 +243,21 @@ struct SortMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Stems Only filter. It only applies once there's a stems index (and, for albums and
|
||||
/// artists, once the library has been matched against it), so nothing vanishes while loading.
|
||||
@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 func albums(_ list: [Album]) -> [Album] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
}
|
||||
static func artists(_ list: [Artist]) -> [Artist] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artists ---------------------------------------------------------------------------------
|
||||
|
||||
struct ArtistsScreen: View {
|
||||
@@ -243,7 +271,8 @@ struct ArtistsScreen: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
Loaded(state: artists, retry: { reload += 1 }) { list in
|
||||
Loaded(state: artists, retry: { reload += 1 }) { all in
|
||||
let list = StemsOnly.artists(all)
|
||||
ForEach(list) { a in
|
||||
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain)
|
||||
@@ -257,14 +286,14 @@ struct ArtistsScreen: View {
|
||||
}
|
||||
.background(p.bg)
|
||||
.overlay(alignment: .trailing) {
|
||||
if sort.isDefault, let list = artists.value, list.count > 30 {
|
||||
if sort.isDefault, let list = artists.value.map({ StemsOnly.artists($0) }), list.count > 30 {
|
||||
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -327,13 +356,13 @@ struct AlbumsScreen: View {
|
||||
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)
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -350,6 +379,8 @@ struct SongsScreen: View {
|
||||
@State private var error: String?
|
||||
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
|
||||
@State private var gen = 0
|
||||
/// How far into the unfiltered listing we've read; Stems Only drops rows as they arrive.
|
||||
@State private var offset = 0
|
||||
private let sort = SortState("songs", SONG_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
@@ -364,7 +395,7 @@ struct SongsScreen: View {
|
||||
var body: some View {
|
||||
Page(title: "Songs") {
|
||||
if !tracks.isEmpty {
|
||||
if total > 0 {
|
||||
if total > 0, !StemsOnly.on, !sort.option.random {
|
||||
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
}
|
||||
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
||||
@@ -384,21 +415,31 @@ struct SongsScreen: View {
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: sort.param) {
|
||||
gen += 1; tracks = []; total = -1; error = nil; loading = false
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") {
|
||||
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() {
|
||||
if loading || (total >= 0 && tracks.count >= total) { return }
|
||||
if loading || (total >= 0 && offset >= total) { return }
|
||||
loading = true
|
||||
let mine = gen, order = sort.param
|
||||
let mine = gen, order = sort.param, random = sort.option.random
|
||||
Task {
|
||||
do {
|
||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
|
||||
// Random pages would repeat songs, so it's one shuffled batch.
|
||||
let page: [Track], n: Int
|
||||
if random {
|
||||
page = try await g.plex.randomTracks(1000); n = page.count
|
||||
} else {
|
||||
let r = try await g.plex.tracksPage(start: offset, size: 200, sort: order)
|
||||
page = r.0; n = r.1
|
||||
}
|
||||
guard mine == gen else { return }
|
||||
tracks += page; total = n; error = nil
|
||||
let kept = StemsOnly.tracks(page)
|
||||
tracks += kept; offset += page.count; total = n; error = nil
|
||||
// A page that the filter mostly emptied leaves nothing on screen to trigger the next one.
|
||||
if kept.count < 40, !page.isEmpty, offset < n { loading = false; loadMore(); return }
|
||||
} catch {
|
||||
guard mine == gen else { return }
|
||||
self.error = error.localizedDescription
|
||||
@@ -687,7 +728,7 @@ struct GenreScreen: View {
|
||||
var body: some View {
|
||||
Page(title: genre) {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
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)") {
|
||||
|
||||
@@ -47,8 +47,10 @@ struct PlaylistsScreen: View {
|
||||
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
|
||||
.task(id: "\(g.settings.musicSection)|\(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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ struct SettingsScreen: View {
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -270,8 +271,11 @@ struct PlexAccountScreen: View {
|
||||
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
|
||||
// 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) {
|
||||
@@ -281,7 +285,7 @@ struct PlexAccountScreen: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
if s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -291,7 +295,7 @@ struct PlexAccountScreen: View {
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity).themedRow()
|
||||
}
|
||||
} header: { Header(text: "Music Library") }
|
||||
} header: { Header(text: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one 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") {
|
||||
|
||||
Reference in new issue
Block a user