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.
1214 lines
50 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|