NavidromeApp/iOS/Views/Library/MyMusicView.swift
Dallas Groot f3b9483b23 overhaul
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
2026-04-11 11:17:40 -07:00

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)
}
}
}
}
}