Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+5 -5
View File
@@ -60,14 +60,14 @@ struct Artwork: View {
}
}
/// Settings.app-style coloured rounded-square icon.
/// A settings icon: the glyph in its colour on a faint wash of the same colour.
struct SettingsIcon: View {
let symbol: String
let bg: UInt32
var body: some View {
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(Color(hex: bg))
.frame(width: 30, height: 30)
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
.background(Color(hex: bg).opacity(0.16), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
@@ -124,8 +124,8 @@ struct ErrorRow: View {
@Environment(Ui.self) private var ui
var body: some View {
if !AppGraph.shared.plex.signedIn {
EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") {
ui.push(.PlexAccount)
EmptyState(icon: "server.rack", title: "Not Connected", message: "Add your Plex or Jellyfin server to see your music here.", action: "Connect") {
ui.push(.Servers)
}
} else {
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
+43 -41
View File
@@ -1,7 +1,7 @@
import SwiftUI
/// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
/// offer the front or end of the queue, download, and add-to-playlist (playlists included).
/// offer Play Next, Add to Queue, download (whole albums, artists and playlists, like Plexamp), and add-to-playlist.
/// Each call returns the actions; follow-up choices go through [Overlays].
@MainActor
struct MediaMenu {
@@ -11,9 +11,7 @@ struct MediaMenu {
func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
var a = queueActions { [t] }
if g.downloads.records[t.ratingKey] == nil {
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") })
} else {
if g.downloads.looseRecords.contains(where: { $0.track.ratingKey == t.ratingKey }) {
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]) })
@@ -30,46 +28,44 @@ struct MediaMenu {
}
func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
let have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track)
let cov = g.coverage.albums[al.ratingKey]
let total = cov?.total ?? al.trackCount ?? Int.max
var more: [SheetAction] = []
if allowGoTo, let k = al.artistKey {
more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) })
}
let plex = g.plex
return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) },
let gr = DownloadGroup(kind: .album, key: al.ratingKey, title: al.title, subtitle: al.artist, thumb: al.thumb)
return collection("\(al.title) — \(al.artist)", gr, { try await plex.albumTracks(al.ratingKey) },
playlistKey: nil, extra: more + extra, stemmed: stemmed(cov))
}
func artist(_ ar: Artist) -> [SheetAction] {
let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track)
let cov = g.coverage.artists[ar.ratingKey]
let plex = g.plex
return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { try await plex.artistTracks(ar.ratingKey) },
let gr = DownloadGroup(kind: .artist, key: ar.ratingKey, title: ar.title, subtitle: "Artist", thumb: ar.thumb)
return collection(ar.title, gr, { try await plex.artistTracks(ar.ratingKey) },
playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov))
}
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
let plex = g.plex
let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) }
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
let gr = DownloadGroup(kind: .playlist, key: pl.ratingKey, title: pl.title, subtitle: "Playlist", thumb: pl.thumb)
return collection(pl.title, gr, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
confirmDownload: pl.trackCount > 50)
}
func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] {
var a = [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
]
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
if !missing.isEmpty {
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
}
a.append(downloadAction(pl.title, DownloadGroup(kind: .stemPlaylist, key: pl.id, title: pl.title, subtitle: "Stem Playlist", thumb: pl.thumb),
{ pl.tracks }, confirm: false))
if pl.items.isEmpty { a = [] }
let lists = g.stemPlaylists
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
@@ -82,10 +78,10 @@ struct MediaMenu {
func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] {
let lists = g.stemPlaylists
return [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
SheetAction("Change Mix…", icon: "slider.horizontal.3") {
@@ -97,9 +93,11 @@ struct MediaMenu {
/// For songs already in hand (downloads with no Plex album behind them).
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil }
return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: extra, stemmed: stemmed)
let remove = SheetAction("Remove Download", icon: "trash", destructive: true) {
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(tracks) }
}
return collection(title, nil, { tracks }, playlistKey: nil, extra: [remove] + extra, stemmed: stemmed)
}
/// True when every song is known to have stems, so there's nothing for Stem Now to do.
@@ -108,43 +106,46 @@ struct MediaMenu {
return cov.withStems >= cov.total
}
private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track],
private func collection(_ title: String, _ group: DownloadGroup?, _ load: @escaping () async throws -> [Track],
playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] {
var a = queueActions(load)
if !allDownloaded {
a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") {
withTracks(load) { tracks in
let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") }
if confirmDownload && tracks.count > 20 {
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
} else {
go()
}
}
})
}
if !downloaded.isEmpty {
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
})
}
if let group { a.append(downloadAction(title, group, load, confirm: confirmDownload)) }
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
if !stemmed { a.append(stemNow(load)) }
return a + extra
}
/// Front of the queue (right after this song) and the end of it.
/// Play Next (right after this song) and Add to Queue (the end of it).
private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] {
[
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") {
withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") {
withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }
},
]
}
/// Download, or Remove Download once [group] is downloaded. A downloaded group keeps itself in step with the server.
func downloadAction(_ title: String, _ group: DownloadGroup, _ load: @escaping () async throws -> [Track], confirm: Bool) -> SheetAction {
if g.downloads.groups[group.id] != nil {
return SheetAction("Remove Download", icon: "trash", destructive: true) {
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(group.id) }
}
}
return SheetAction("Download", icon: "arrow.down.circle") {
withTracks(load) { tracks in
let go = { g.downloads.download(group, tracks); overlays.hud("Downloading", "arrow.down.circle") }
if confirm && tracks.count > 20 {
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
} else {
go()
}
}
}
}
/// Has stemd on the server separate the songs that lack stems ahead of the rest of the library.
private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction {
SheetAction("Stem Now", icon: "waveform") {
@@ -189,6 +190,7 @@ struct MediaMenu {
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
}
}
for c in CustomPreset.load() { acts.append(SheetAction(c.name, icon: "slider.horizontal.3") { then(c.mix) }) }
for p in MixPresets.all {
guard let m = p.build(StemPlaylists.allStems) else { continue }
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
+8 -5
View File
@@ -87,6 +87,7 @@ struct AlbumGrid<Badge: View>: View {
var showArtist = true
var subtitle: ((Album) -> String?)?
var menu: ((Album) -> [SheetAction])?
var columns = 2
let open: (Album) -> Void
@ViewBuilder var badge: (Album) -> Badge
@@ -94,7 +95,9 @@ struct AlbumGrid<Badge: View>: View {
var body: some View {
let cov = AppGraph.shared.coverage.albums
LazyVGrid(columns: [GridItem(.flexible(), spacing: 16, alignment: .top), GridItem(.flexible(), spacing: 16, alignment: .top)], spacing: 18) {
let small = columns >= 4
let gap: CGFloat = columns > 2 ? 10 : 16
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: gap, alignment: .top), count: columns), spacing: small ? 12 : 18) {
ForEach(albums) { a in
VStack(alignment: .leading, spacing: 0) {
GeometryReader { geo in
@@ -103,12 +106,12 @@ struct AlbumGrid<Badge: View>: View {
}
.aspectRatio(1, contentMode: .fit)
HStack(spacing: 6) {
Text(a.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
Text(a.title).font((small ? Font.caption : .subheadline).weight(.medium)).foregroundStyle(p.label).lineLimit(1)
StemFlag(cov: cov[a.ratingKey])
}
.padding(.top, 6)
let sub = subtitle?(a) ?? (showArtist ? a.artist : a.year.map(String.init) ?? "")
if !sub.isEmpty { Text(sub).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) }
if !sub.isEmpty { Text(sub).font(small ? .caption : .subheadline).foregroundStyle(p.label2).lineLimit(1) }
}
.contentShape(Rectangle())
.onTapGesture { open(a) }
@@ -120,8 +123,8 @@ struct AlbumGrid<Badge: View>: View {
}
extension AlbumGrid where Badge == EmptyView {
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, open: @escaping (Album) -> Void) {
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, open: open) { _ in EmptyView() }
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, columns: Int = 2, open: @escaping (Album) -> Void) {
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, columns: columns, open: open) { _ in EmptyView() }
}
}