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>
416 lines
19 KiB
Swift
416 lines
19 KiB
Swift
import SwiftUI
|
|
|
|
// ---- playlists ------------------------------------------------------------------------------
|
|
|
|
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
|
|
|
|
struct PlaylistsScreen: View {
|
|
@Environment(Ui.self) private var ui
|
|
@State private var lists: LoadState<[Playlist]> = .loading
|
|
@State private var reload = 0
|
|
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
|
|
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
Page(title: "Playlists") {
|
|
Picker("Kind", selection: $kind) {
|
|
Text("Plex").tag(PlaylistKind.Plex)
|
|
Text("Stem").tag(PlaylistKind.Stem)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, MARGIN).padding(.bottom, 8)
|
|
if kind == .Plex {
|
|
Loaded(state: lists, retry: { reload += 1 }) { list in
|
|
if list.isEmpty {
|
|
EmptyState(icon: "music.note.list", title: "No Playlists",
|
|
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
|
|
}
|
|
ForEach(list) { pl in
|
|
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
|
}
|
|
}
|
|
} else {
|
|
let stems = g.stemPlaylists.lists
|
|
if stems.isEmpty {
|
|
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
|
|
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
|
|
}
|
|
ForEach(stems) { pl in
|
|
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
|
|
}
|
|
}
|
|
}
|
|
.toolbar {
|
|
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
|
}
|
|
}
|
|
.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) }
|
|
}
|
|
}
|
|
|
|
/// Plex can't hold an empty playlist, so new ones start from songs you have.
|
|
private func newPlaylist() {
|
|
let queue = g.playback.queue
|
|
var acts: [SheetAction] = []
|
|
if !queue.isEmpty {
|
|
acts.append(SheetAction("New Playlist from Queue (\(queue.count))") { ui.menu.newPlaylist(queue.map(\.track)) { reload += 1 } })
|
|
}
|
|
if let now = g.playback.current?.track {
|
|
acts.append(SheetAction("New Playlist with “\(now.title)”") { ui.menu.newPlaylist([now]) { reload += 1 } })
|
|
}
|
|
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
|
else { ui.overlays.actions("New Playlist", acts) }
|
|
}
|
|
|
|
private func newStemPlaylist() {
|
|
let menu = ui.menu
|
|
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
|
|
ui.overlays.actions("New Stem Playlist", [
|
|
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
|
|
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
|
|
Task {
|
|
guard let plex = try? await g.plex.playlists() else {
|
|
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
|
return
|
|
}
|
|
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
|
|
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
|
|
}
|
|
},
|
|
])
|
|
}
|
|
}
|
|
|
|
struct StemPlaylistRow: View {
|
|
let playlist: StemPlaylist
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
HStack(spacing: 14) {
|
|
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
|
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Tag(text: "STEMS")
|
|
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
|
}
|
|
.frame(height: 76)
|
|
.contentShape(Rectangle())
|
|
.listRow(inset: 94, trailing: 16)
|
|
}
|
|
}
|
|
|
|
/// A stem playlist: lives on this device, and every song plays with its saved mix.
|
|
struct StemPlaylistScreen: View {
|
|
let id: String
|
|
@Environment(Ui.self) private var ui
|
|
@Environment(\.palette) private var p
|
|
@State private var editing = false
|
|
@State private var items: [StemPlaylistItem] = []
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
if let pl = g.stemPlaylists.list(id) {
|
|
page(pl)
|
|
} else {
|
|
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
|
|
}
|
|
}
|
|
|
|
private func page(_ pl: StemPlaylist) -> some View {
|
|
Group {
|
|
if editing {
|
|
List {
|
|
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
|
ForEach(items) { it in
|
|
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
|
|
}
|
|
.onDelete { items.remove(atOffsets: $0) }
|
|
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(p.bg)
|
|
.environment(\.editMode, .constant(.active))
|
|
.navigationTitle(pl.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
} else {
|
|
Page(title: pl.title, large: false) {
|
|
header(pl)
|
|
if pl.items.isEmpty {
|
|
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
|
|
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
|
|
}
|
|
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
|
|
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
|
|
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
|
|
}
|
|
.listRow(inset: MARGIN + 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
|
|
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
|
|
if !editing { MoreMenu(items: moreItems(pl)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func header(_ pl: StemPlaylist) -> some View {
|
|
VStack(spacing: 0) {
|
|
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
|
|
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
|
.padding(.horizontal, 32).padding(.top, 14)
|
|
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
|
|
if !editing && !pl.items.isEmpty {
|
|
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
|
|
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 8).padding(.bottom, 16)
|
|
}
|
|
|
|
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
|
|
let lists = g.stemPlaylists
|
|
let rename = SheetAction("Rename…", icon: "pencil") {
|
|
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
|
|
}
|
|
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
|
|
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
|
|
}
|
|
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
|
|
}
|
|
}
|
|
|
|
struct PlaylistRow: View {
|
|
let playlist: Playlist
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
HStack(spacing: 14) {
|
|
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "music.note.list")
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
|
Text(songs(playlist.trackCount)).font(.subheadline).foregroundStyle(p.label2)
|
|
}
|
|
Spacer(minLength: 8)
|
|
if playlist.smart { Tag(text: "SMART") }
|
|
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
|
}
|
|
.frame(height: 76)
|
|
.contentShape(Rectangle())
|
|
.listRow(inset: 94, trailing: 16)
|
|
}
|
|
}
|
|
|
|
struct PlaylistScreen: View {
|
|
let playlist: Playlist
|
|
@Environment(Ui.self) private var ui
|
|
@Environment(\.palette) private var p
|
|
@State private var tracks: LoadState<[Track]> = .loading
|
|
@State private var reload = 0
|
|
@State private var title: String
|
|
@State private var editing = false
|
|
@State private var items: [Track] = []
|
|
@State private var removed: [Track] = []
|
|
@State private var saving = false
|
|
private var g: AppGraph { .shared }
|
|
|
|
init(playlist: Playlist) {
|
|
self.playlist = playlist
|
|
_title = State(initialValue: playlist.title)
|
|
}
|
|
|
|
var body: some View {
|
|
let list = tracks.value ?? []
|
|
Group {
|
|
if editing {
|
|
// Native list editing: delete buttons on the left, grips on the right.
|
|
List {
|
|
header(list).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
|
ForEach(items) { t in
|
|
TrackRow(track: t) {}.listRowBackground(p.bg)
|
|
}
|
|
.onDelete { idx in
|
|
removed += idx.map { items[$0] }
|
|
items.remove(atOffsets: idx)
|
|
}
|
|
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(p.bg)
|
|
.environment(\.editMode, .constant(.active))
|
|
.navigationTitle(title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
} else {
|
|
Page(title: title, large: false) {
|
|
header(list)
|
|
Loaded(state: tracks, retry: { reload += 1 }) { list in
|
|
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
|
|
TrackRow(track: t, menu: ui.menu.track(t, extra: removeAction(t))) { g.playback.playTracks(list, start: i) }
|
|
.listRow(inset: MARGIN + 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
if !playlist.smart && !list.isEmpty {
|
|
if saving { ProgressView() }
|
|
else if editing { Button("Done") { commit(list) }.bold() }
|
|
else { Button("Edit") { items = list; removed = []; editing = true } }
|
|
}
|
|
if !editing { MoreMenu(items: moreItems(list)) }
|
|
}
|
|
}
|
|
.task(id: reload) {
|
|
await LoadState.load($tracks) { try await g.plex.playlistTracks(playlist.ratingKey) }
|
|
}
|
|
}
|
|
|
|
private func header(_ list: [Track]) -> some View {
|
|
VStack(spacing: 0) {
|
|
Artwork(thumb: playlist.thumb, size: 230, corner: 10, placeholder: "music.note.list")
|
|
Text(title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
|
.padding(.horizontal, 32).padding(.top, 14)
|
|
if !list.isEmpty {
|
|
Text("\(songs(list.count)) · \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))").font(.subheadline).foregroundStyle(p.label2)
|
|
}
|
|
if !editing {
|
|
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) }).padding(.top, 14)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 8).padding(.bottom, 16)
|
|
}
|
|
|
|
private func removeAction(_ t: Track) -> [SheetAction] {
|
|
if playlist.smart { return [] }
|
|
return [SheetAction("Remove from Playlist", icon: "minus.circle", destructive: true) {
|
|
Task {
|
|
if let id = t.playlistItemId { try? await g.plex.removeFromPlaylist(playlist.ratingKey, playlistItemId: id) }
|
|
reload += 1
|
|
}
|
|
}]
|
|
}
|
|
|
|
private func moreItems(_ list: [Track]) -> [SheetAction] {
|
|
var extra: [SheetAction] = []
|
|
let plex = g.plex
|
|
let key = playlist.ratingKey
|
|
if !playlist.smart {
|
|
extra.append(SheetAction("Rename…", icon: "pencil") {
|
|
ui.overlays.prompt("Rename Playlist", nil, initial: title, "Save") { name in
|
|
Task { if (try? await plex.renamePlaylist(key, title: name)) != nil { title = name } }
|
|
}
|
|
})
|
|
}
|
|
extra.append(SheetAction("Delete Playlist", icon: "trash", destructive: true) {
|
|
ui.overlays.confirm("Delete “\(title)”?", "This removes it from Plex for every app.", "Delete") {
|
|
Task { if (try? await plex.deletePlaylist(key)) != nil { ui.pop() } }
|
|
}
|
|
})
|
|
var pl = playlist
|
|
pl.title = title
|
|
pl.trackCount = list.count
|
|
return ui.menu.playlist(pl, extra: extra)
|
|
}
|
|
|
|
/// Sends removals, then replays the new order as "move after" calls against a simulated server list.
|
|
private func commit(_ original: [Track]) {
|
|
let target = items
|
|
let gone = removed
|
|
let plex = g.plex
|
|
let key = playlist.ratingKey
|
|
saving = true
|
|
Task {
|
|
do {
|
|
for t in gone { if let id = t.playlistItemId { try await plex.removeFromPlaylist(key, playlistItemId: id) } }
|
|
let goneIds = Set(gone.compactMap(\.playlistItemId))
|
|
var sim = original.filter { !goneIds.contains($0.playlistItemId ?? -1) }
|
|
for (i, t) in target.enumerated() {
|
|
guard let id = t.playlistItemId, i >= sim.count || sim[i].playlistItemId != id else { continue }
|
|
try await plex.movePlaylistItem(key, playlistItemId: id, afterItemId: i == 0 ? nil : target[i - 1].playlistItemId)
|
|
sim.removeAll { $0.playlistItemId == id }
|
|
sim.insert(t, at: min(i, sim.count))
|
|
}
|
|
} catch {
|
|
ui.overlays.hud("Couldn't save: \(error.localizedDescription)", "exclamationmark.triangle")
|
|
}
|
|
reload += 1
|
|
saving = false
|
|
editing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- search ---------------------------------------------------------------------------------
|
|
|
|
struct SearchScreen: View {
|
|
@Environment(Ui.self) private var ui
|
|
@State private var query = ""
|
|
@State private var results: LoadState<SearchResults>?
|
|
@State private var retry = 0
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
let q = query.trimmingCharacters(in: .whitespaces)
|
|
Page(title: "Search") {
|
|
if let results {
|
|
Loaded(state: results, retry: { retry += 1 }) { s in
|
|
if s.isEmpty { EmptyState(icon: "magnifyingglass", title: "No Results", message: "Nothing matches “\(q)”.") }
|
|
if !s.artists.isEmpty {
|
|
SectionTitle(title: "Artists")
|
|
ForEach(s.artists) { a in
|
|
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
|
.buttonStyle(.plain).actionsMenu(ui.menu.artist(a)).listRow(inset: MARGIN + 58, trailing: 16)
|
|
}
|
|
}
|
|
if !s.albums.isEmpty {
|
|
SectionTitle(title: "Albums")
|
|
AlbumGrid(albums: s.albums, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
|
}
|
|
if !s.tracks.isEmpty {
|
|
SectionTitle(title: "Songs")
|
|
ForEach(Array(s.tracks.enumerated()), id: \.element.id) { i, t in
|
|
TrackRow(track: t, subtitle: "\(t.artist) — \(t.album)", menu: ui.menu.track(t)) { g.playback.playTracks(s.tracks, start: i) }
|
|
.listRow(inset: MARGIN + 60)
|
|
}
|
|
}
|
|
if !s.playlists.isEmpty {
|
|
SectionTitle(title: "Playlists")
|
|
ForEach(s.playlists) { pl in
|
|
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
EmptyState(icon: "magnifyingglass", title: "Search Your Library", message: "Find artists, albums, songs and playlists on your Plex server.")
|
|
}
|
|
}
|
|
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Albums, Songs")
|
|
.autocorrectionDisabled()
|
|
.task(id: "\(q)|\(retry)") {
|
|
guard q.count >= 2 else { results = nil; return }
|
|
try? await Task.sleep(nanoseconds: 280_000_000)
|
|
if Task.isCancelled { return }
|
|
results = .loading
|
|
do { results = .ok(try await g.plex.search(q)) }
|
|
catch is CancellationError {}
|
|
catch { results = .err(error.localizedDescription) }
|
|
}
|
|
}
|
|
}
|