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
@@ -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)") {
|
||||
|
||||
Reference in new issue
Block a user