NavidromeApp/iOS/Views/Library/MyMusicView.swift
Dallas Groot fc69d8a3cf CPU: Remove @Published from AudioPlayer time properties
Replace @Published var currentTime/duration with plain vars and drive
progress bars via TimelineView(.periodic) instead of SwiftUI
observation. This stops objectWillChange from firing 20x/second on
AudioPlayer, eliminating continuous body re-evaluation on
NowPlayingView, MiniPlayerBar, and MyMusicView regardless of
visualizer state.
2026-04-11 16:44:56 -07:00

1289 lines
54 KiB
Swift

import SwiftUI
import PhotosUI
struct MyMusicView: View {
@EnvironmentObject var serverManager: ServerManager
// Deliberately NOT @EnvironmentObject / @ObservedObject on AudioPlayer.
// Observing AudioPlayer directly subscribes this view to objectWillChange,
// which fires 10x/second from the currentTime periodic observer. With a
// large allSongs list that causes continuous `initializeWithCopy for
// MyMusicView` in Instruments at ~128% CPU even with nothing on screen.
//
// Instead: track only the one @Published property we actually need in the
// body (currentSong.id for row highlight) via @State + .onReceive. All
// playback actions use AudioPlayer.shared directly no observation required
// for button callbacks.
@State private var currentSongId: String?
@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 { refreshData() }
// Sync currentSongId whenever the playing song changes.
// This is the ONLY AudioPlayer property MyMusicView observes
// currentTime changes at 10Hz do not reach this view at all.
.onReceive(AudioPlayer.shared.$currentSong) { song in
currentSongId = song?.id
}
.onReceive(NotificationCenter.default.publisher(for: .companionLibraryChanged)) { _ in
Task { 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 {
// isSongAvailableOffline checks exact ID first, then title/artist/duration
// fallback handles songs whose Navidrome ID changed after a Companion
// API tag edit triggered a file restructure (AUDIT: offline song fix)
let isDownloaded = offlineManager.isSongAvailableOffline(song)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
let available = isDownloaded || libraryCache.isServerAvailable
Button(action: {
if available {
AudioPlayer.shared.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(.rowTitle)
.foregroundColor(
!available ? .gray.opacity(0.35) :
currentSongId == song.id ? accentPink : .white
)
.lineLimit(1)
Text("\(song.artist ?? "") · \(song.album ?? "")")
.font(.rowSubtitle)
.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.shared.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { AudioPlayer.shared.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { AudioPlayer.shared.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { AudioPlayer.shared.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.shared.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.shared.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.shared.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
//
// MyMusicView reads exclusively from LibraryCache it does NOT make its own
// server requests. SyncEngine owns all server communication and populates the
// cache on a 15-minute schedule (and immediately on Companion push events).
//
// This eliminates the duplicate full album pagination that previously ran on
// every pull-to-refresh (AUDIT-032/065): MyMusicView was making the exact same
// getAlbumList2 calls as SyncEngine, doubling network traffic and writing to
// the same cache keys concurrently.
//
// Pull-to-refresh now triggers SyncEngine.forceSync() one code path, one set
// of API calls, one writer to LibraryCache.
private func loadData() async {
let cache = LibraryCache.shared
// Always load from cache first instant on warm launch
if recentAlbums.isEmpty, let v = cache.loadAlbums() { recentAlbums = v }
if playlists.isEmpty, let v = cache.loadPlaylists() { playlists = v }
if artists.isEmpty, let v = cache.loadArtists() { artists = v }
if let v = cache.load([Genre].self, key: "genres") { genres = v }
if let v = cache.load([Album].self, key: "all_albums") { allAlbums = v }
if favouriteSongs.isEmpty, let v = cache.load([Song].self, key: "starred_songs") { favouriteSongs = v }
isLoading = false
// If the cache was empty (first launch or after a clear), kick off a sync
// and wait for it to fill the cache, then reload.
let cacheWasEmpty = recentAlbums.isEmpty && playlists.isEmpty && artists.isEmpty
if cacheWasEmpty {
isLoading = true
await SyncEngine.shared.syncAndWait()
if let v = cache.loadAlbums() { recentAlbums = v }
if let v = cache.loadPlaylists() { playlists = v }
if let v = cache.loadArtists() { artists = v }
if let v = cache.load([Genre].self, key: "genres") { genres = v }
if let v = cache.load([Album].self, key: "all_albums") { allAlbums = v }
if let v = cache.load([Song].self, key: "starred_songs") { favouriteSongs = v }
isLoading = false
}
}
/// Pull-to-refresh: delegate to SyncEngine rather than running a second
/// set of paginated API calls. SyncEngine writes to LibraryCache and posts
/// companionLibraryChanged the .onReceive below reloads the view.
private func refreshData() {
Task {
SyncEngine.shared.forceSync()
// Small wait to allow the sync to start before re-reading cache
try? await Task.sleep(nanoseconds: 500_000_000)
await loadData()
}
}
}
// 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)
}
}
}
}
}