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:
rootandClaude Opus 5.5 committed 2026-10-20 18:29:45 -07:00
1 parent 48faa6682f
commit 7cdfbef160
16 files changed
+2006 -730

No files matched your search

+56 -15
View File
@@ -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)") {