NavidromeApp/iOS/Views/Library/MyMusicView.swift
Dallas Groot 2bdac607b4 bug fixes
Songs Tab (SearchView.swift)
Default state now loads all songs alphabetically from the library via
getAlbumList2 → per-album song fetch, cached under "all_songs_sorted"
so subsequent opens are instant. The Download All banner shows song
count + already-downloaded count and queues only non-downloaded songs.
Every row uses .contextMenu (the long-press menu) with Play Now, Play
Next, Add to Queue, Download/Remove, Send to Watch, and Add to
Playlist — same pattern as Favourites. Watch and download badges
appear on each row. Searching ≥2 chars runs the server search and
shows artists/albums/songs in sections, then clears back to the full
list when the field is empty.
Keyboard Done Button
A single keyboardDoneButton() View extension in AsyncCoverArt.swift
calls UIApplication.shared.sendAction(resignFirstResponder:...)
globally — no @FocusState needed. Applied to: LoginView (all 4
fields), CompanionSettingsView (host/port), TrackEditorView
(checkField helper covers all tag fields), BatchAlbumEditorSheet
(editField helper), RadioView (name/URL), PlaylistsView (name fields),
MyMusicView (search), SearchView (via @FocusState + toolbar directly).
ShazamKit MTAudioProcessingTap
Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix
— works for HLS, radio, and any AVPlayer stream without touching the
microphone. The prepare callback captures the source format and builds
an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free
function (required by the API) calls
MTAudioProcessingTapGetSourceAudio then dispatches to a serial
analysisQueue — the render thread is never blocked. convertAndMatch
wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and
feeds SHSession.matchStreamingBuffer. Fallback to microphone
(AVAudioEngine) is kept for the local engine path where no
AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if
the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00

1214 lines
50 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 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)
private var songsTab: some View {
let filtered = searchText.isEmpty ? allSongs : allSongs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText) ||
($0.album ?? "").localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
songRow(song: song, index: index, allSongs: filtered)
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
if filtered.isEmpty && !isLoading {
emptyState("No songs found")
}
}
.padding(.top, 4)
}
@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
Button(action: {
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
await MainActor.run {
favouriteSongs.removeAll { $0.id == song.id }
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
}
} else {
try? await serverManager.client.star(id: song.id)
await MainActor.run {
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.
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
var seen = Set<String>()
var result: [Album] = []
for album in albums {
let key = "\(album.name)|\(album.coverArt ?? "")"
if seen.contains(key) { continue }
seen.insert(key)
// Check if multiple artists share this album
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
if sameAlbum.count > 1 {
// Replace artist with "Various Artists"
let grouped = Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
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
self.allSongs = [] // Songs loaded on-demand via search
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)
}
}
}
}
}