AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
1296 lines
53 KiB
Swift
1296 lines
53 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
|
|
struct MyMusicView: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
@EnvironmentObject var audioPlayer: AudioPlayer
|
|
@EnvironmentObject var offlineManager: OfflineManager
|
|
@Binding var navigateToPlaylistId: String?
|
|
@Binding var navigateToAlbumId: String?
|
|
@Binding var navigateToArtistId: String?
|
|
|
|
@State private var recentAlbums: [Album] = []
|
|
@State private var allAlbums: [Album] = []
|
|
@State private var allSongs: [Song] = []
|
|
@State private var songsLoaded = false
|
|
@State private var isLoadingSongs = false
|
|
@State private var playlists: [Playlist] = []
|
|
@State private var artists: [ArtistIndex] = []
|
|
@State private var genres: [Genre] = []
|
|
@State private var favouriteSongs: [Song] = []
|
|
@State private var isLoading = true
|
|
@State private var sortMode: SortMode = .recentlyAdded
|
|
@State private var showServerPicker = false
|
|
@State private var showCreatePlaylist = false
|
|
@State private var newPlaylistName = ""
|
|
@State private var searchText = ""
|
|
|
|
// Album selection mode for batch editing
|
|
@State private var isSelectingAlbums = false
|
|
@State private var selectedAlbumIds: Set<String> = []
|
|
@State private var showBatchAlbumEditor = false
|
|
@State private var singleEditAlbumId: String?
|
|
@State private var showSingleAlbumEditor = false
|
|
|
|
// Artist cover picker
|
|
@State private var showArtistCoverPicker = false
|
|
@State private var artistCoverPickerItem: PhotosPickerItem?
|
|
@State private var artistCoverTargetId: String?
|
|
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
|
|
@ObservedObject private var libraryCache = LibraryCache.shared
|
|
|
|
// Song info / edit
|
|
@State private var getInfoSong: Song?
|
|
@State private var trackEditorSong: Song?
|
|
|
|
// Playlist rename
|
|
@State private var showRenamePlaylist = false
|
|
@State private var renamePlaylistId: String?
|
|
@State private var renamePlaylistName = ""
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
enum SortMode: String, CaseIterable {
|
|
case recentlyAdded = "Recently Added"
|
|
case favourites = "Favourites"
|
|
case artists = "Artists"
|
|
case albums = "Albums"
|
|
case songs = "Songs"
|
|
case genres = "Genres"
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Tab pills
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(SortMode.allCases, id: \.self) { mode in
|
|
Button(action: {
|
|
sortMode = mode
|
|
searchText = ""
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
if mode == .favourites {
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 11))
|
|
}
|
|
Text(mode == .favourites ? "Favourites" : mode.rawValue)
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
|
|
.foregroundColor(sortMode == mode ? .white : .gray)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// Search bar (for all tabs except Recently Added)
|
|
if sortMode != .recentlyAdded {
|
|
searchBar
|
|
}
|
|
|
|
// Content
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
switch sortMode {
|
|
case .recentlyAdded: recentlyAddedTab
|
|
case .favourites: favouritesTab
|
|
case .artists: artistsTab
|
|
case .albums: albumsTab
|
|
case .songs: songsTab
|
|
case .genres: genresTab
|
|
}
|
|
Color.clear.frame(height: 100)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(white: 0.06))
|
|
.navigationTitle("My Music")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
if isSelectingAlbums {
|
|
Button(action: {
|
|
isSelectingAlbums = false
|
|
selectedAlbumIds.removeAll()
|
|
}) {
|
|
Text("Cancel")
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .principal) {
|
|
if isSelectingAlbums {
|
|
Text("\(selectedAlbumIds.count) selected")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if isSelectingAlbums {
|
|
Button(action: { showBatchAlbumEditor = true }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "tag")
|
|
Text("Edit")
|
|
}
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
|
|
}
|
|
.disabled(selectedAlbumIds.isEmpty)
|
|
} else {
|
|
Button(action: { showServerPicker = true }) {
|
|
Image(systemName: "server.rack")
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showServerPicker) {
|
|
ServerPickerSheet(isPresented: $showServerPicker)
|
|
}
|
|
.alert("New Playlist", isPresented: $showCreatePlaylist) {
|
|
TextField("Playlist name", text: $newPlaylistName)
|
|
Button("Create") {
|
|
let name = newPlaylistName
|
|
newPlaylistName = ""
|
|
Task {
|
|
try? await serverManager.client.createPlaylist(name: name)
|
|
await loadData()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { newPlaylistName = "" }
|
|
}
|
|
.alert("Rename Playlist", isPresented: $showRenamePlaylist) {
|
|
TextField("Name", text: $renamePlaylistName)
|
|
Button("Save") {
|
|
let newName = renamePlaylistName
|
|
guard let pid = renamePlaylistId else { return }
|
|
Task {
|
|
try? await serverManager.client.updatePlaylist(id: pid, name: newName)
|
|
await loadData()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { }
|
|
}
|
|
.task { await loadData() }
|
|
.refreshable { await loadData() }
|
|
.sheet(isPresented: $showBatchAlbumEditor) {
|
|
MultiAlbumEditorSheet(albumIds: Array(selectedAlbumIds)) {
|
|
isSelectingAlbums = false
|
|
selectedAlbumIds.removeAll()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSingleAlbumEditor) {
|
|
if let albumId = singleEditAlbumId {
|
|
MultiAlbumEditorSheet(albumIds: [albumId])
|
|
}
|
|
}
|
|
.sheet(item: $getInfoSong) { song in
|
|
SongInfoSheet(song: song)
|
|
}
|
|
.sheet(item: $trackEditorSong) { song in
|
|
TrackEditorView(song: song)
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToPlaylistId != nil },
|
|
set: { if !$0 { navigateToPlaylistId = nil } }
|
|
)) {
|
|
if let pid = navigateToPlaylistId {
|
|
PlaylistDetailView(playlistId: pid)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToAlbumId != nil },
|
|
set: { if !$0 { navigateToAlbumId = nil } }
|
|
)) {
|
|
if let aid = navigateToAlbumId {
|
|
AlbumDetailView(albumId: aid)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToArtistId != nil },
|
|
set: { if !$0 { navigateToArtistId = nil } }
|
|
)) {
|
|
if let aid = navigateToArtistId {
|
|
ArtistDetailView(artistId: aid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Bar
|
|
|
|
private var searchBar: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(.gray)
|
|
.font(.system(size: 14))
|
|
TextField("Search \(sortMode.rawValue.lowercased())...", text: $searchText)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.white)
|
|
.autocorrectionDisabled()
|
|
.keyboardDoneButton()
|
|
if !searchText.isEmpty {
|
|
Button(action: { searchText = "" }) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.gray)
|
|
.font(.system(size: 14))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color.white.opacity(0.08))
|
|
.cornerRadius(10)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - Recently Added Tab
|
|
|
|
private var recentlyAddedTab: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
recentlyAddedSection
|
|
playlistsSection
|
|
}
|
|
}
|
|
|
|
private var recentlyAddedSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Recently Added")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
NavigationLink(destination: AlbumGridView(title: "Recently Added", type: "newest")) {
|
|
Text("more")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 14) {
|
|
ForEach(deduplicateAlbums(Array(recentAlbums.prefix(20)))) { album in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 160)
|
|
.frame(width: 160, height: 160)
|
|
.cornerRadius(4)
|
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
|
}
|
|
|
|
Button(action: { playAlbum(album) }) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 4)
|
|
}
|
|
.padding(6)
|
|
}
|
|
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(album.name)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
|
|
Text(album.artist ?? "")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 160)
|
|
.contextMenu {
|
|
Button(action: { playAlbum(album) }) {
|
|
Label("Play", systemImage: "play.fill")
|
|
}
|
|
Button(action: { playAlbumNext(album) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
Button(action: { playAlbumLater(album) }) {
|
|
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
Divider()
|
|
|
|
Button(action: {
|
|
singleEditAlbumId = album.id
|
|
showSingleAlbumEditor = true
|
|
}) {
|
|
Label("Edit Album", systemImage: "tag")
|
|
}
|
|
|
|
Button(action: {
|
|
selectedAlbumIds = [album.id]
|
|
isSelectingAlbums = true
|
|
sortMode = .albums
|
|
}) {
|
|
Label("Select Albums", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Playlists Section
|
|
|
|
private var playlistsSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Playlists")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
Button(action: { showCreatePlaylist = true }) {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 12)
|
|
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(playlists) { playlist in
|
|
NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: playlist.coverArt, size: 56)
|
|
.frame(width: 52, height: 52)
|
|
.cornerRadius(4)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(playlist.name)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text("\(playlist.songCount ?? 0) songs")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
renamePlaylistId = playlist.id
|
|
renamePlaylistName = playlist.name
|
|
showRenamePlaylist = true
|
|
}) {
|
|
Label("Rename Playlist", systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive, action: {
|
|
Task {
|
|
try? await serverManager.client.deletePlaylist(id: playlist.id)
|
|
await loadData()
|
|
}
|
|
}) {
|
|
Label("Delete Playlist", systemImage: "trash")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 86)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Favourites Tab
|
|
|
|
private var favouritesTab: some View {
|
|
let filtered = searchText.isEmpty ? favouriteSongs : favouriteSongs.filter {
|
|
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
return VStack(spacing: 0) {
|
|
// Header buttons
|
|
if !favouriteSongs.isEmpty {
|
|
HStack(spacing: 12) {
|
|
Button(action: downloadAllFavourites) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.down.circle")
|
|
Text("Download All")
|
|
}
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(accentPink.opacity(0.15))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
if WatchConnectivityManager.shared.isWatchAvailable {
|
|
Button(action: sendAllFavouritesToWatch) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "applewatch.and.arrow.forward")
|
|
Text("Send to Watch")
|
|
}
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(accentPink.opacity(0.15))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(favouriteSongs.count) songs")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// Song list with swipe-to-delete
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
|
|
songRow(song: song, index: index, allSongs: filtered)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
removeFavourite(song)
|
|
} label: {
|
|
Label("Unfavourite", systemImage: "heart.slash")
|
|
}
|
|
.tint(.red)
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.leading, 72)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState(searchText.isEmpty ? "No favourites yet — star songs to see them here" : "No matches")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
private func removeFavourite(_ song: Song) {
|
|
Task {
|
|
try? await serverManager.client.unstar(id: song.id)
|
|
await MainActor.run {
|
|
favouriteSongs.removeAll { $0.id == song.id }
|
|
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func downloadAllFavourites() {
|
|
guard let server = serverManager.activeServer else { return }
|
|
for song in favouriteSongs {
|
|
if !offlineManager.isSongDownloaded(song.id) {
|
|
offlineManager.downloadSong(song, server: server)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendAllFavouritesToWatch() {
|
|
for song in favouriteSongs {
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
|
}
|
|
}
|
|
|
|
// MARK: - Artists Tab
|
|
|
|
private var artistsTab: some View {
|
|
let flatArtists = artists.flatMap { $0.artist ?? [] }
|
|
let filtered = searchText.isEmpty ? flatArtists : flatArtists.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
return LazyVStack(spacing: 0) {
|
|
ForEach(filtered) { artist in
|
|
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
|
|
HStack(spacing: 14) {
|
|
if let customImage = artistCoverStore.loadCover(for: artist.id) {
|
|
Image(uiImage: customImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 48, height: 48)
|
|
.clipShape(Circle())
|
|
} else {
|
|
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
|
|
.frame(width: 48, height: 48)
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
Text(artist.name)
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
|
|
Spacer()
|
|
|
|
if let count = artist.albumCount {
|
|
Text("\(count) albums")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
artistCoverTargetId = artist.id
|
|
showArtistCoverPicker = true
|
|
}) {
|
|
Label("Change Artist Cover", systemImage: "photo.on.rectangle")
|
|
}
|
|
|
|
if artistCoverStore.hasCover(for: artist.id) {
|
|
Button(role: .destructive, action: {
|
|
artistCoverStore.removeCover(for: artist.id)
|
|
}) {
|
|
Label("Remove Custom Cover", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 78)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No artists found")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
.photosPicker(
|
|
isPresented: $showArtistCoverPicker,
|
|
selection: $artistCoverPickerItem,
|
|
matching: .images
|
|
)
|
|
.onChange(of: artistCoverPickerItem) { _, newItem in
|
|
guard let item = newItem, let targetId = artistCoverTargetId else { return }
|
|
Task {
|
|
if let data = try? await item.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
artistCoverStore.saveCover(image, for: targetId)
|
|
}
|
|
artistCoverPickerItem = nil
|
|
artistCoverTargetId = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Albums Tab (grid with selection)
|
|
|
|
private var albumsTab: some View {
|
|
let deduped = deduplicateAlbums(allAlbums)
|
|
let filtered = searchText.isEmpty ? deduped : deduped.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
|
|
|
|
return VStack(spacing: 0) {
|
|
// Quick actions bar in selection mode
|
|
if isSelectingAlbums {
|
|
HStack(spacing: 16) {
|
|
Button(action: {
|
|
if selectedAlbumIds.count == filtered.count {
|
|
selectedAlbumIds.removeAll()
|
|
} else {
|
|
selectedAlbumIds = Set(filtered.map { $0.id })
|
|
}
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: selectedAlbumIds.count == filtered.count ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 14))
|
|
Text(selectedAlbumIds.count == filtered.count ? "Deselect All" : "Select All")
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
.foregroundColor(accentPink)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Color.white.opacity(0.03))
|
|
}
|
|
|
|
LazyVGrid(columns: columns, spacing: 18) {
|
|
ForEach(filtered) { album in
|
|
if isSelectingAlbums {
|
|
// Selection mode: tap to toggle selection
|
|
albumTile(album)
|
|
.overlay(alignment: .topLeading) {
|
|
Image(systemName: selectedAlbumIds.contains(album.id) ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(selectedAlbumIds.contains(album.id) ? accentPink : .white.opacity(0.5))
|
|
.shadow(radius: 4)
|
|
.padding(6)
|
|
}
|
|
.opacity(selectedAlbumIds.isEmpty || selectedAlbumIds.contains(album.id) ? 1 : 0.5)
|
|
.onTapGesture {
|
|
if selectedAlbumIds.contains(album.id) {
|
|
selectedAlbumIds.remove(album.id)
|
|
} else {
|
|
selectedAlbumIds.insert(album.id)
|
|
}
|
|
}
|
|
} else {
|
|
// Normal mode: navigate + context menu
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
albumTile(album)
|
|
}
|
|
.contextMenu {
|
|
Button(action: { playAlbum(album) }) {
|
|
Label("Play", systemImage: "play.fill")
|
|
}
|
|
Button(action: { playAlbumNext(album) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
Button(action: { playAlbumLater(album) }) {
|
|
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
Divider()
|
|
|
|
Button(action: {
|
|
singleEditAlbumId = album.id
|
|
showSingleAlbumEditor = true
|
|
}) {
|
|
Label("Edit Album", systemImage: "tag")
|
|
}
|
|
|
|
Button(action: {
|
|
selectedAlbumIds = [album.id]
|
|
isSelectingAlbums = true
|
|
}) {
|
|
Label("Select Albums", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No albums found")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reusable album tile (cover + title + artist)
|
|
private func albumTile(_ album: Album) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
|
|
.frame(height: 160)
|
|
.cornerRadius(4)
|
|
|
|
Text(album.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
|
|
Text(album.artist ?? "")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Songs Tab (list)
|
|
|
|
/// First letter of a song title for section headers, collapsing digits/symbols to "#"
|
|
private func sectionKey(for song: Song) -> String {
|
|
let first = song.title.first ?? "#"
|
|
return first.isLetter ? String(first).uppercased() : "#"
|
|
}
|
|
|
|
private var songsTab: some View {
|
|
let filtered: [Song] = {
|
|
let base = allSongs
|
|
guard !searchText.isEmpty else { return base }
|
|
return base.filter {
|
|
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText) ||
|
|
($0.album ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}()
|
|
|
|
// Group into alphabetical sections
|
|
let grouped: [(String, [Song])] = {
|
|
var dict: [String: [Song]] = [:]
|
|
for song in filtered {
|
|
let key = sectionKey(for: song)
|
|
dict[key, default: []].append(song)
|
|
}
|
|
// Letters A-Z then # for digits/symbols
|
|
let letters = dict.keys.filter { $0 != "#" }.sorted()
|
|
let keys = dict["#"] != nil ? letters + ["#"] : letters
|
|
return keys.compactMap { k in dict[k].map { (k, $0) } }
|
|
}()
|
|
|
|
return LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
|
|
if isLoadingSongs {
|
|
ProgressView()
|
|
.tint(accentPink)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 60)
|
|
} else if filtered.isEmpty {
|
|
emptyState("No songs found")
|
|
} else {
|
|
ForEach(grouped, id: \.0) { (letter, songs) in
|
|
Section {
|
|
ForEach(Array(songs.enumerated()), id: \.element.id) { idx, song in
|
|
songRow(song: song, index: idx, allSongs: Array(filtered))
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.leading, 72)
|
|
}
|
|
} header: {
|
|
Text(letter)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.gray)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
.background(Color.black.opacity(0.85))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
.task {
|
|
guard !songsLoaded, !isLoadingSongs else { return }
|
|
await loadAllSongs()
|
|
}
|
|
}
|
|
|
|
private func loadAllSongs() async {
|
|
// Cache hit → instant display
|
|
if let cached = LibraryCache.shared.load([Song].self, key: "all_songs_sorted") {
|
|
allSongs = cached
|
|
songsLoaded = true
|
|
return
|
|
}
|
|
isLoadingSongs = true
|
|
do {
|
|
var collected: [Song] = []
|
|
var offset = 0
|
|
while true {
|
|
let result = try await serverManager.client.search3(
|
|
query: "", artistCount: 0, artistOffset: 0,
|
|
albumCount: 0, albumOffset: 0,
|
|
songCount: 500, songOffset: offset)
|
|
let page = result?.song ?? []
|
|
collected.append(contentsOf: page)
|
|
if page.count < 500 { break }
|
|
offset += 500
|
|
}
|
|
let sorted = collected.sorted {
|
|
$0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending
|
|
}
|
|
LibraryCache.shared.save(sorted, key: "all_songs_sorted")
|
|
allSongs = sorted
|
|
songsLoaded = true
|
|
isLoadingSongs = false
|
|
} catch {
|
|
isLoadingSongs = false
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
|
|
let isDownloaded = offlineManager.isSongDownloaded(song.id)
|
|
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
|
|
let dlState = offlineManager.downloads[song.id]
|
|
let available = isDownloaded || libraryCache.isServerAvailable
|
|
|
|
Button(action: {
|
|
if available {
|
|
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
|
|
}
|
|
}) {
|
|
HStack(spacing: 12) {
|
|
// Cover art with download progress overlay
|
|
ZStack {
|
|
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
|
|
.frame(width: 44, height: 44)
|
|
.cornerRadius(3)
|
|
.opacity(available ? 1.0 : 0.4)
|
|
|
|
if case .downloading(let progress) = dlState {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(Color.black.opacity(0.5))
|
|
.frame(width: 44, height: 44)
|
|
Circle()
|
|
.trim(from: 0, to: progress)
|
|
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
.frame(width: 24, height: 24)
|
|
.rotationEffect(.degrees(-90))
|
|
} else if case .queued = dlState {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(Color.black.opacity(0.4))
|
|
.frame(width: 44, height: 44)
|
|
ProgressView().tint(accentPink).scaleEffect(0.6)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(song.title)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(
|
|
!available ? .gray.opacity(0.35) :
|
|
audioPlayer.currentSong?.id == song.id ? accentPink : .white
|
|
)
|
|
.lineLimit(1)
|
|
Text("\(song.artist ?? "") · \(song.album ?? "")")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(available ? .gray : .gray.opacity(0.3))
|
|
.lineLimit(1)
|
|
|
|
// Download progress bar
|
|
if case .downloading(let progress) = dlState {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
|
|
Capsule().fill(accentPink)
|
|
.frame(width: geo.size.width * progress, height: 2)
|
|
}
|
|
}
|
|
.frame(height: 2)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Status icons
|
|
HStack(spacing: 4) {
|
|
if isOnWatch {
|
|
Image(systemName: "applewatch")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.green)
|
|
}
|
|
if isDownloaded {
|
|
Image(systemName: "arrow.down.circle.fill")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.green.opacity(0.6))
|
|
}
|
|
}
|
|
|
|
Text(song.durationFormatted)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.contextMenu {
|
|
Button(action: { audioPlayer.playNow(song) }) {
|
|
Label("Play Now", systemImage: "play.fill")
|
|
}
|
|
Button(action: { audioPlayer.playNext(song) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
Button(action: { audioPlayer.playLater(song) }) {
|
|
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
|
|
Label("Instant Mix", systemImage: "wand.and.stars")
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Favourite toggle — routes through OptimisticActionQueue for offline resilience.
|
|
// Direct client calls with try? silently lose the action when Tailscale is reconnecting.
|
|
Button(action: {
|
|
if song.starred != nil {
|
|
OptimisticActionQueue.shared.unstar(songId: song.id)
|
|
favouriteSongs.removeAll { $0.id == song.id }
|
|
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
|
} else {
|
|
OptimisticActionQueue.shared.star(songId: song.id)
|
|
if !favouriteSongs.contains(where: { $0.id == song.id }) {
|
|
favouriteSongs.append(song)
|
|
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
|
}
|
|
}
|
|
}) {
|
|
Label(song.starred != nil ? "Unfavourite" : "Favourite",
|
|
systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Download
|
|
if isDownloaded {
|
|
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
|
|
Label("Remove Download", systemImage: "trash")
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
if let server = serverManager.activeServer {
|
|
offlineManager.downloadSong(song, server: server)
|
|
}
|
|
}) {
|
|
Label("Download", systemImage: "arrow.down.circle")
|
|
}
|
|
}
|
|
|
|
// Send to Watch (always visible)
|
|
Button(action: {
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
|
}) {
|
|
Label(
|
|
isOnWatch ? "On Watch ✓" : "Send to Watch",
|
|
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward"
|
|
)
|
|
}
|
|
.disabled(isOnWatch)
|
|
|
|
Divider()
|
|
|
|
Button(action: { getInfoSong = song }) {
|
|
Label("Get Info", systemImage: "info.circle")
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
Button(action: { trackEditorSong = song }) {
|
|
Label("Edit Tags", systemImage: "tag")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Genres Tab
|
|
|
|
private var genresTab: some View {
|
|
let filtered = searchText.isEmpty ? genres : genres.filter {
|
|
$0.value.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
return LazyVStack(spacing: 0) {
|
|
ForEach(filtered) { genre in
|
|
NavigationLink(destination: AlbumGridView(title: genre.value, type: "byGenre", genre: genre.value)) {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "music.note.list")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(accentPink)
|
|
.frame(width: 36, height: 36)
|
|
.background(Color.white.opacity(0.08))
|
|
.cornerRadius(8)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(genre.value)
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
HStack(spacing: 8) {
|
|
if let sc = genre.songCount {
|
|
Text("\(sc) songs")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
if let ac = genre.albumCount {
|
|
Text("\(ac) albums")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 66)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No genres found")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
private func emptyState(_ text: String) -> some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.gray)
|
|
Text(text)
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 60)
|
|
}
|
|
|
|
// MARK: - Play Album
|
|
|
|
private func playAlbum(_ album: Album) {
|
|
Task {
|
|
do {
|
|
let albumDetail = try await serverManager.client.getAlbum(id: album.id)
|
|
if let songs = albumDetail?.song, !songs.isEmpty {
|
|
await MainActor.run {
|
|
audioPlayer.play(song: songs[0], fromQueue: songs, at: 0)
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
private func playAlbumNext(_ album: Album) {
|
|
Task {
|
|
if let detail = try? await serverManager.client.getAlbum(id: album.id),
|
|
let songs = detail.song {
|
|
await MainActor.run {
|
|
for song in songs.reversed() { audioPlayer.playNext(song) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func playAlbumLater(_ album: Album) {
|
|
Task {
|
|
if let detail = try? await serverManager.client.getAlbum(id: album.id),
|
|
let songs = detail.song {
|
|
await MainActor.run {
|
|
for song in songs { audioPlayer.playLater(song) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Album Deduplication
|
|
// Navidrome returns one album entry per artist for compilations.
|
|
// Group by name + coverArt to show each album once.
|
|
// O(n) implementation: build frequency map in one pass, deduplicate in second pass.
|
|
|
|
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
|
|
// Count how many distinct artist entries share the same name+cover key
|
|
var keyCount: [String: Int] = [:]
|
|
for album in albums {
|
|
let key = "\(album.name)|\(album.coverArt ?? "")"
|
|
keyCount[key, default: 0] += 1
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
var result: [Album] = []
|
|
result.reserveCapacity(keyCount.count)
|
|
|
|
for album in albums {
|
|
let key = "\(album.name)|\(album.coverArt ?? "")"
|
|
guard !seen.contains(key) else { continue }
|
|
seen.insert(key)
|
|
|
|
if keyCount[key, default: 1] > 1 {
|
|
// Multiple artist entries — replace with "Various Artists"
|
|
let grouped = Album(
|
|
id: album.id, name: album.name,
|
|
artist: "Various Artists", artistId: nil,
|
|
albumArtist: "Various Artists",
|
|
coverArt: album.coverArt, songCount: album.songCount,
|
|
duration: album.duration, playCount: album.playCount,
|
|
created: album.created, starred: album.starred,
|
|
year: album.year, genre: album.genre
|
|
)
|
|
result.append(grouped)
|
|
} else {
|
|
result.append(album)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
private func loadData() async {
|
|
let client = serverManager.client
|
|
let cache = LibraryCache.shared
|
|
|
|
// Load cached data FIRST — show instantly, never spinner on warm launch
|
|
let hadCache: Bool
|
|
if recentAlbums.isEmpty, let cached = cache.loadAlbums() { recentAlbums = cached }
|
|
if playlists.isEmpty, let cached = cache.loadPlaylists() { playlists = cached }
|
|
if artists.isEmpty, let cached = cache.loadArtists() { artists = cached }
|
|
if let cached = cache.load([Genre].self, key: "genres") { genres = cached }
|
|
if let cached = cache.load([Album].self, key: "all_albums") { allAlbums = cached }
|
|
if favouriteSongs.isEmpty, let cached = cache.load([Song].self, key: "starred_songs") { favouriteSongs = cached }
|
|
|
|
hadCache = !recentAlbums.isEmpty || !playlists.isEmpty || !artists.isEmpty
|
|
|
|
// If we have cache, stop showing spinner immediately
|
|
if hadCache { isLoading = false }
|
|
|
|
// Then refresh from server in background
|
|
do {
|
|
async let albumsReq = client.getAlbumList2(type: "newest", size: 30)
|
|
async let playlistsReq = client.getPlaylists()
|
|
async let artistsReq = client.getArtists()
|
|
async let genresReq = client.getGenres()
|
|
async let starredReq = client.getStarred2()
|
|
|
|
let (albums, playlistList, artistList, genreList, starred) = try await (
|
|
albumsReq, playlistsReq, artistsReq, genresReq, starredReq
|
|
)
|
|
|
|
// Fetch ALL albums (paginated)
|
|
let albumsFull = try await fetchAllAlbums(client: client)
|
|
|
|
cache.cacheAlbums(albums)
|
|
cache.cachePlaylists(playlistList)
|
|
cache.cacheArtists(artistList)
|
|
cache.save(genreList, key: "genres")
|
|
cache.save(albumsFull, key: "all_albums")
|
|
if let starredSongs = starred?.song {
|
|
cache.save(starredSongs, key: "starred_songs")
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.recentAlbums = albums
|
|
self.playlists = playlistList
|
|
self.artists = artistList
|
|
self.allAlbums = albumsFull
|
|
self.genres = genreList
|
|
if let starredSongs = starred?.song {
|
|
self.favouriteSongs = starredSongs
|
|
}
|
|
self.isLoading = false
|
|
}
|
|
} catch {
|
|
serverManager.handleConnectionFailure()
|
|
await MainActor.run { self.isLoading = false }
|
|
}
|
|
}
|
|
|
|
/// Paginate all albums from the server
|
|
private func fetchAllAlbums(client: SubsonicClient) async throws -> [Album] {
|
|
var all: [Album] = []
|
|
var offset = 0
|
|
let pageSize = 500
|
|
while true {
|
|
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
|
|
all.append(contentsOf: page)
|
|
if page.count < pageSize { break }
|
|
offset += pageSize
|
|
}
|
|
// Sort: alpha first, then numerals, then special characters
|
|
return all.sorted { a, b in
|
|
let aFirst = a.name.first ?? Character("\0")
|
|
let bFirst = b.name.first ?? Character("\0")
|
|
let aIsLetter = aFirst.isLetter
|
|
let bIsLetter = bFirst.isLetter
|
|
if aIsLetter && !bIsLetter { return true }
|
|
if !aIsLetter && bIsLetter { return false }
|
|
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Picker Sheet
|
|
struct ServerPickerSheet: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
@Binding var isPresented: Bool
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Active Server") {
|
|
ForEach(serverManager.servers) { server in
|
|
Button(action: {
|
|
Task {
|
|
_ = await serverManager.switchServer(server)
|
|
isPresented = false
|
|
}
|
|
}) {
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text(server.name)
|
|
.foregroundColor(.white)
|
|
Text(server.url)
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
Spacer()
|
|
if serverManager.activeServer?.id == server.id {
|
|
Image(systemName: "checkmark")
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let msg = serverManager.lastFailoverMessage {
|
|
Section {
|
|
Text(msg)
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Servers")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { isPresented = false }
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|