Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
900 lines
36 KiB
Swift
900 lines
36 KiB
Swift
import SwiftUI
|
|
|
|
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 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 = ""
|
|
|
|
// Pagination for songs
|
|
@State private var songOffset = 0
|
|
@State private var hasMoreSongs = true
|
|
@State private var albumOffset = 0
|
|
@State private var hasMoreAlbums = true
|
|
|
|
// 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
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
enum SortMode: String, CaseIterable {
|
|
case recentlyAdded = "Recently Added"
|
|
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 = ""
|
|
}) {
|
|
Text(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 .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 = "" }
|
|
}
|
|
.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])
|
|
}
|
|
}
|
|
.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()
|
|
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")
|
|
}
|
|
|
|
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(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: - 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) {
|
|
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)
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 78)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No artists found")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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 hasMoreAlbums && searchText.isEmpty {
|
|
Button(action: { Task { await loadMoreAlbums() } }) {
|
|
Text("Load More")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
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 hasMoreSongs && searchText.isEmpty {
|
|
Button(action: { Task { await loadMoreSongs() } }) {
|
|
Text("Load More")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No songs found")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
|
|
Button(action: {
|
|
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
|
|
}) {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
|
|
.frame(width: 44, height: 44)
|
|
.cornerRadius(3)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(song.title)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(audioPlayer.currentSong?.id == song.id ? accentPink : .white)
|
|
.lineLimit(1)
|
|
Text("\(song.artist ?? "") · \(song.album ?? "")")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
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()
|
|
if offlineManager.isSongDownloaded(song.id) {
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Fallback: navigate to album detail
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
|
|
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 allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
|
|
async let genresReq = client.getGenres()
|
|
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
|
|
|
|
let (albums, playlistList, artistList, albumsFull, genreList, searchResult) = try await (
|
|
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq
|
|
)
|
|
|
|
cache.cacheAlbums(albums)
|
|
cache.cachePlaylists(playlistList)
|
|
cache.cacheArtists(artistList)
|
|
cache.save(genreList, key: "genres")
|
|
cache.save(albumsFull, key: "all_albums")
|
|
|
|
await MainActor.run {
|
|
self.recentAlbums = albums
|
|
self.playlists = playlistList
|
|
self.artists = artistList
|
|
self.allAlbums = albumsFull
|
|
self.albumOffset = 100
|
|
self.hasMoreAlbums = albumsFull.count >= 100
|
|
self.genres = genreList
|
|
self.allSongs = searchResult?.song ?? []
|
|
self.songOffset = 100
|
|
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
|
|
self.isLoading = false
|
|
}
|
|
} catch {
|
|
serverManager.handleConnectionFailure()
|
|
await MainActor.run { self.isLoading = false }
|
|
}
|
|
}
|
|
|
|
private func loadMoreAlbums() async {
|
|
do {
|
|
let more = try await serverManager.client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: albumOffset)
|
|
await MainActor.run {
|
|
allAlbums.append(contentsOf: more)
|
|
albumOffset += 100
|
|
hasMoreAlbums = more.count >= 100
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
private func loadMoreSongs() async {
|
|
do {
|
|
let result = try await serverManager.client.search3(query: "", songCount: 100, songOffset: songOffset)
|
|
let moreSongs = result?.song ?? []
|
|
await MainActor.run {
|
|
allSongs.append(contentsOf: moreSongs)
|
|
songOffset += 100
|
|
hasMoreSongs = moreSongs.count >= 100
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|