- MTAudioProcessingTap with lock-free ring buffer + pre-allocated vDSP FFT - Tap installs on readyToPlay (fixes 'no audio track' on live streams) - Shared tap serves FFT + Shazam simultaneously (no more tap conflict) - Ring buffer reset on removeTap (prevents stale FFT frames) - Simulation runs as placeholder until stream connects - All 6 paths verified: start, station change, goLive, seekBack, bg/fg, radio→music Bug fixes: - playerItem tracking in radioGoLive/radioSeekBack (was orphaned) - Combine sinks: .receive(on: .main) on all 4 notification handlers - ShazamRecognizer stopAll: audio session now actually restores after mic fallback - processTapBuffer/convertAndMatch methods restored (were dropped in refactor) Lyrics: - Bottom toolbar on overlay: Search, Timing (±0.1s/±0.5s inline), Edit - LyricsManager.globalOffset applied to syncToTime + wordProgress - openLyricsEditor notification wired to NowPlayingView UI: - Server selection button hidden when Dynamic Island active - Debug: Capture Audio Tap (5s WAV) with share sheet in Visualizer Settings"
1541 lines
65 KiB
Swift
1541 lines
65 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?
|
|
@Binding var isDynamicIsland: Bool
|
|
|
|
@State private var recentAlbums: [Album] = []
|
|
@State private var recentlyPlayedAlbums: [Album] = []
|
|
@State private var randomAlbums: [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 recentlyPlayed = "Recently Played"
|
|
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))
|
|
} else if mode == .recentlyPlayed {
|
|
Image(systemName: "clock.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 and Recently Played)
|
|
if sortMode != .recentlyAdded && sortMode != .recentlyPlayed {
|
|
searchBar
|
|
}
|
|
|
|
// Content
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
switch sortMode {
|
|
case .recentlyAdded: recentlyAddedTab
|
|
case .recentlyPlayed: recentlyPlayedTab
|
|
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 if !isDynamicIsland {
|
|
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
|
|
discoverSection
|
|
playlistsSection
|
|
}
|
|
}
|
|
|
|
// MARK: - Recently Played Tab
|
|
|
|
private var recentlyPlayedTab: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Recently Played")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
|
|
if recentlyPlayedAlbums.isEmpty {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 36))
|
|
.foregroundColor(.gray)
|
|
Text("No recently played albums")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.gray)
|
|
Text("Albums will appear here as you listen.")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(Color(white: 0.4))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 40)
|
|
} else {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(recentlyPlayedAlbums) { album in
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 60)
|
|
.frame(width: 56, height: 56)
|
|
.cornerRadius(4)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(album.name)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text(album.artist ?? "")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: { playAlbum(album) }) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.contextMenu {
|
|
Button(action: { playAlbum(album) }) {
|
|
Label("Play", systemImage: "play.fill")
|
|
}
|
|
Button(action: { playAlbumNext(album) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
if recentlyPlayedAlbums.isEmpty {
|
|
await loadRecentlyPlayed()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadRecentlyPlayed() async {
|
|
do {
|
|
let albums = try await serverManager.client.getAlbumList2(type: "recent", size: 30)
|
|
await MainActor.run { recentlyPlayedAlbums = albums }
|
|
} catch {
|
|
print("[MyMusicView] Failed to load recently played: \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Discover Section
|
|
|
|
private var discoverSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Discover")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
Button(action: { Task { await refreshRandom() } }) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 20)
|
|
|
|
// Random Albums horizontal scroll
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 14) {
|
|
// Shuffle All button
|
|
Button(action: { Task { await playRandomMix() } }) {
|
|
VStack(spacing: 8) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [accentPink.opacity(0.3), accentPink.opacity(0.1)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 140, height: 140)
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "shuffle")
|
|
.font(.system(size: 28, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
Text("Shuffle All")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("I'm Feeling Lucky")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text("Random mix")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(width: 140)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Random albums
|
|
ForEach(deduplicateAlbums(randomAlbums)) { album in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 140)
|
|
.frame(width: 140, height: 140)
|
|
.cornerRadius(4)
|
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
|
}
|
|
|
|
Button(action: { playAlbum(album) }) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 4)
|
|
}
|
|
.padding(4)
|
|
}
|
|
|
|
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: 140)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
// Genre quick-play chips
|
|
if !genres.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(genres.prefix(12)) { genre in
|
|
Button(action: { Task { await playGenreMix(genre.value) } }) {
|
|
Text(genre.value)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.08))
|
|
.cornerRadius(14)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshRandom() async {
|
|
do {
|
|
let albums = try await serverManager.client.getAlbumList2(type: "random", size: 10)
|
|
await MainActor.run { randomAlbums = albums }
|
|
} catch {
|
|
print("[MyMusicView] Failed to load random albums: \(error)")
|
|
}
|
|
}
|
|
|
|
private func playRandomMix() async {
|
|
do {
|
|
let songs = try await serverManager.client.getRandomSongs(size: 50)
|
|
if !songs.isEmpty {
|
|
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
|
|
}
|
|
} catch {
|
|
print("[MyMusicView] Failed to play random mix: \(error)")
|
|
}
|
|
}
|
|
|
|
private func playGenreMix(_ genre: String) async {
|
|
do {
|
|
let songs = try await serverManager.client.getRandomSongs(size: 50, genre: genre)
|
|
if !songs.isEmpty {
|
|
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
|
|
}
|
|
} catch {
|
|
print("[MyMusicView] Failed to play genre mix \(genre): \(error)")
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// Load random albums for Discover section (always fresh, not cached)
|
|
if randomAlbums.isEmpty {
|
|
Task { await refreshRandom() }
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|