iOS: stem playlist refresh and covers, download queue page, reorderable Library, whole-library radio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c8dfb54ca
commit
a4c870251c
18 files changed
+514
-30
No files matched your search
@@ -15,7 +15,14 @@ struct MediaMenu {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil {
|
||||
let s = g.stems.state(t)
|
||||
if s == .stemming || s == .queued {
|
||||
a.append(SheetAction(s == .stemming ? "Stemming…" : "Queued for Stems", icon: "waveform") {})
|
||||
} else {
|
||||
a.append(stemNow { [t] })
|
||||
}
|
||||
}
|
||||
if allowGoTo, let k = t.albumKey {
|
||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||
ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)))
|
||||
@@ -68,6 +75,17 @@ struct MediaMenu {
|
||||
{ pl.tracks }, confirm: false))
|
||||
if pl.items.isEmpty { a = [] }
|
||||
let lists = g.stemPlaylists
|
||||
if !pl.items.isEmpty {
|
||||
a.append(SheetAction("Change Cover…", icon: "photo") {
|
||||
var seen = Set<String>()
|
||||
var acts: [SheetAction] = [SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) }]
|
||||
for t in pl.tracks {
|
||||
guard let th = t.thumb, seen.insert(th).inserted, seen.count <= 20 else { continue }
|
||||
acts.append(SheetAction(t.album, icon: th == pl.artThumb ? "checkmark" : "photo") { lists.setArt(pl.id, th) })
|
||||
}
|
||||
overlays.actions("Choose a Cover", acts)
|
||||
})
|
||||
}
|
||||
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
|
||||
})
|
||||
|
||||
@@ -56,7 +56,14 @@ struct TrackRow<Leading: View>: View {
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
Spacer(minLength: 6)
|
||||
if noStems { Tag(text: "NO STEMS").padding(.trailing, 6) }
|
||||
if noStems {
|
||||
switch g.stems.state(track) {
|
||||
case .stemming: Tag(text: "STEMMING").padding(.trailing, 6)
|
||||
case .queued: Tag(text: "QUEUED").padding(.trailing, 6)
|
||||
case .failed: Tag(text: "FAILED").padding(.trailing, 6)
|
||||
case nil: Tag(text: "NO STEMS").padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) }
|
||||
else if downloaded { DownloadedMark().padding(.trailing, 4) }
|
||||
if let menu { MoreMenu(items: menu).foregroundStyle(p.label) }
|
||||
|
||||
@@ -36,9 +36,9 @@ enum Tab: String, CaseIterable, Hashable {
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar, WebRadio
|
||||
case Artists, Albums, Songs, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case Servers, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
|
||||
case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
|
||||
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ struct RootView: View {
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Genres: GenresScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
case .DownloadQueue: DownloadQueueScreen()
|
||||
case .TabBar: TabBarScreen()
|
||||
case .WebRadio: WebRadioScreen()
|
||||
case .GenrePage(let g): GenreScreen(genre: g)
|
||||
@@ -68,6 +69,7 @@ struct RootView: View {
|
||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
||||
case .Servers: ServersScreen()
|
||||
case .ServerList: ServerListScreen()
|
||||
case .Server(let id): ServerScreen(id: id)
|
||||
case .StemServer: StemServerScreen()
|
||||
case .Quality: QualityScreen()
|
||||
|
||||
@@ -9,8 +9,25 @@ struct LibraryHome: View {
|
||||
@State private var played: [Album] = []
|
||||
@State private var added: [Track] = []
|
||||
@State private var reload = 0
|
||||
@State private var editing = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
/// Rows in the saved order. Titles not saved yet keep their default order after the saved ones.
|
||||
private func ordered<T>(_ items: [T], _ title: (T) -> String) -> [T] {
|
||||
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
|
||||
func rank(_ t: String) -> Int { saved.firstIndex(of: t) ?? saved.count }
|
||||
return items.enumerated().sorted { (rank(title($0.element)), $0.offset) < (rank(title($1.element)), $1.offset) }.map(\.element)
|
||||
}
|
||||
|
||||
/// Moves a row one place within its section and saves the whole order.
|
||||
private func move(_ title: String, _ by: Int, in titles: [String]) {
|
||||
guard let i = titles.firstIndex(of: title), titles.indices.contains(i + by) else { return }
|
||||
var t = titles
|
||||
t.swapAt(i, i + by)
|
||||
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
|
||||
g.settings.libraryOrder = (t + saved.filter { !t.contains($0) }).joined(separator: "|")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let ready = s.libraryReady
|
||||
@@ -63,15 +80,18 @@ 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) }),
|
||||
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.allTracksShuffled() }),
|
||||
("square.stack", "Random Album Radio", {
|
||||
// Whole albums in random order, across the entire library.
|
||||
var out: [Track] = []
|
||||
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) }
|
||||
for a in try await g.plex.albums().shuffled() { 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) }
|
||||
let shown = ordered(stations) { $0.1 }
|
||||
ForEach(shown, id: \.1) { st in
|
||||
LibraryRow(icon: st.0, title: st.1, last: st.1 == shown.last?.1, editing: editing,
|
||||
move: { move(st.1, $0, in: shown.map(\.1)) }) { play(st.2) }
|
||||
}
|
||||
}
|
||||
// Every tab except Settings (and this one).
|
||||
@@ -81,8 +101,17 @@ struct LibraryHome: View {
|
||||
("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) }
|
||||
let shownRows = ordered(rows) { $0.1 }
|
||||
ForEach(shownRows, id: \.1) { r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: r.1 == shownRows.last?.1, editing: editing,
|
||||
move: { move(r.1, $0, in: shownRows.map(\.1)) }) { ui.push(r.2) }
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if ready {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(editing ? "Done" : "Reorder") { withAnimation { editing.toggle() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.libraryKey)|\(reload)") {
|
||||
@@ -92,6 +121,7 @@ struct LibraryHome: View {
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
played = Array((await rp ?? []).prefix(24))
|
||||
added = Array((await ra ?? []).prefix(10))
|
||||
if await g.stemPlaylists.refresh(albumTracks: { try await g.plex.albumTracks($0) }) { await g.downloads.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +196,25 @@ private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let last: Bool
|
||||
var editing = false
|
||||
var move: (Int) -> Void = { _ in }
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Button(action: { if !editing { action() } }) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
|
||||
Text(title).font(.title3).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
if editing {
|
||||
HStack(spacing: 18) {
|
||||
Image(systemName: "chevron.up").onTapGesture { move(-1) }
|
||||
Image(systemName: "chevron.down").onTapGesture { move(1) }
|
||||
}
|
||||
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
|
||||
} else {
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
}
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
@@ -472,7 +512,7 @@ struct SongsScreen: View {
|
||||
/// Shuffles the whole library, not just the pages loaded so far.
|
||||
private func shuffleAll() {
|
||||
Task {
|
||||
do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) }
|
||||
do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) }
|
||||
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
@@ -700,7 +740,7 @@ struct DownloadsScreen: View {
|
||||
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
|
||||
})
|
||||
}
|
||||
MoreMenu(items: moreItems(all, pinned))
|
||||
MoreMenu(items: moreItems(all, pinned) + [SheetAction("View Queue", icon: "list.bullet") { ui.push(.DownloadQueue) }])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,3 +853,53 @@ struct GenreScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct DownloadQueueScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let recs = g.downloads.records.values
|
||||
let active = recs.filter { !$0.done && $0.error == nil }.sorted { $0.added < $1.added }
|
||||
let failed = recs.filter { $0.error != nil }.sorted { $0.added < $1.added }
|
||||
let done = recs.filter(\.done).sorted { $0.added > $1.added }
|
||||
List {
|
||||
if active.isEmpty && failed.isEmpty && done.isEmpty {
|
||||
Text("Nothing in the queue").foregroundStyle(.secondary)
|
||||
}
|
||||
if !active.isEmpty {
|
||||
Section("In Progress · \(active.count)") {
|
||||
ForEach(active, id: \.track.ratingKey) { r in
|
||||
let p = g.downloads.progress[r.track.ratingKey] ?? 0
|
||||
row(r, status: p > 0 ? "Downloading \(Int(p * 100))%" : "Waiting", progress: p > 0 ? p : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failed.isEmpty {
|
||||
Section("Failed · \(failed.count)") {
|
||||
ForEach(failed, id: \.track.ratingKey) { r in row(r, status: r.error ?? "Failed", progress: nil) }
|
||||
}
|
||||
}
|
||||
if !done.isEmpty {
|
||||
Section("Completed · \(done.count)") {
|
||||
ForEach(done, id: \.track.ratingKey) { r in row(r, status: r.stems ? "Done · stems" : "Done", progress: nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Download Queue")
|
||||
.toolbar {
|
||||
if !failed.isEmpty {
|
||||
Button("Retry Failed") { g.downloads.retryFailed() }
|
||||
}
|
||||
}
|
||||
.task { await g.downloads.refresh() }
|
||||
}
|
||||
|
||||
private func row(_ r: DownloadRecord, status: String, progress: Float?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(r.track.title).lineLimit(1)
|
||||
Text("\(r.track.artist) · \(status)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
if let progress { ProgressView(value: progress) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,9 +403,8 @@ struct BackupCells: View {
|
||||
/// 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 {
|
||||
struct ServerListScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@State private var libs: [String: [MusicSection]] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@@ -417,7 +416,49 @@ struct ServersScreen: View {
|
||||
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: {
|
||||
} header: { Header(text: "Servers") } footer: {
|
||||
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct ServersScreen: View {
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var libs: [String: [MusicSection]] = [:]
|
||||
@State private var busy = false
|
||||
@State private var status: String?
|
||||
@State private var signInTask: Task<Void, Never>?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let list = g.settings.servers
|
||||
List {
|
||||
Section {
|
||||
if busy {
|
||||
HStack { ProgressView(); Text(status ?? "Working…").foregroundStyle(p.label2) }.themedRow()
|
||||
} else {
|
||||
ActionCell(title: "Sign in with Plex") { signIn() }
|
||||
if !g.settings.plexAccountToken.isEmpty {
|
||||
ActionCell(title: "Refresh Servers") { refresh(g.settings.plexAccountToken) }
|
||||
}
|
||||
if let status { Text(status).font(.footnote).foregroundStyle(p.label2).themedRow() }
|
||||
}
|
||||
} header: { Header(text: "Plex Account") } footer: {
|
||||
Footer(text: "Sign in at plex.tv to add every server on your account and all of their music libraries at once.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Music Servers", value: list.isEmpty ? "None" : "\(list.count)", icon: "server.rack", iconBg: 0xE5A00D, route: .ServerList)
|
||||
} 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
|
||||
@@ -433,20 +474,64 @@ struct ServersScreen: View {
|
||||
}
|
||||
} 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")
|
||||
.navigationTitle("Plex Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
for s in list where s.signedIn && libs[s.id] == nil {
|
||||
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
|
||||
}
|
||||
}
|
||||
.onDisappear { signInTask?.cancel() }
|
||||
}
|
||||
|
||||
/// plex.tv sign-in in the browser, the same flow as the web app.
|
||||
private func signIn() {
|
||||
signInTask?.cancel()
|
||||
busy = true; status = "Waiting for plex.tv…"
|
||||
signInTask = Task {
|
||||
let tv = g.plex.plexTv()
|
||||
do {
|
||||
let pin = try await tv.createStrongPin()
|
||||
guard let url = tv.authUrl(code: pin.code) else { throw URLError(.badURL) }
|
||||
openURL(url)
|
||||
for _ in 0..<150 {
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
if let t = try? await tv.checkPin(pin.id).authToken, !t.isEmpty {
|
||||
await sync(t); return
|
||||
}
|
||||
}
|
||||
status = "Sign-in timed out."
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
status = "Sign-in failed: \(error.localizedDescription)"
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh(_ token: String) {
|
||||
busy = true; status = "Finding servers…"
|
||||
signInTask = Task { await sync(token) }
|
||||
}
|
||||
|
||||
private func sync(_ token: String) async {
|
||||
status = "Finding servers…"
|
||||
do {
|
||||
let r = try await g.plex.syncAccount(token: token)
|
||||
g.settings.plexAccountToken = token
|
||||
libs = [:]
|
||||
status = r.saved == 0 && r.unreachable == 0 ? "No Plex servers on this account."
|
||||
: "\(r.saved) server\(r.saved == 1 ? "" : "s") added or updated" + (r.unreachable > 0 ? ", \(r.unreachable) unreachable." : ".")
|
||||
for s in g.settings.servers where s.signedIn {
|
||||
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
|
||||
}
|
||||
} catch {
|
||||
status = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
|
||||
/// Turns one library of a server on or off. All off keeps the sign-in.
|
||||
@@ -698,6 +783,7 @@ struct StemServerScreen: View {
|
||||
case .unknown: "—"
|
||||
}
|
||||
}()).themedRow()
|
||||
ValueCell(title: "stemd", value: g.stems.liveLine ?? (g.stems.failed.isEmpty ? "Idle" : "\(g.stems.failed.count) failed")).themedRow()
|
||||
ValueCell(title: "Opus Transcoding", value: {
|
||||
if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" }
|
||||
return "—"
|
||||
|
||||
Reference in new issue
Block a user