Active tab — all unignored issues, swipe left → “Ignore” (grey) • Ignored tab — all ignored issues shown dimmed, swipe left → “Restore” (pink) to bring them back • Fix buttons hidden on ignored issues • Ignored IDs persisted in @AppStorage so they survive app restarts • Tab labels show live counts: Active (1) | Ignored (31)
1291 lines
53 KiB
Swift
1291 lines
53 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 songsLoaded = false
|
|
@State private var isLoadingSongs = false
|
|
@State private var playlists: [Playlist] = []
|
|
@State private var artists: [ArtistIndex] = []
|
|
@State private var genres: [Genre] = []
|
|
@State private var favouriteSongs: [Song] = []
|
|
@State private var isLoading = true
|
|
@State private var sortMode: SortMode = .recentlyAdded
|
|
@State private var showServerPicker = false
|
|
@State private var showCreatePlaylist = false
|
|
@State private var newPlaylistName = ""
|
|
@State private var searchText = ""
|
|
|
|
// Album selection mode for batch editing
|
|
@State private var isSelectingAlbums = false
|
|
@State private var selectedAlbumIds: Set<String> = []
|
|
@State private var showBatchAlbumEditor = false
|
|
@State private var singleEditAlbumId: String?
|
|
@State private var showSingleAlbumEditor = false
|
|
|
|
// Artist cover picker
|
|
@State private var showArtistCoverPicker = false
|
|
@State private var artistCoverPickerItem: PhotosPickerItem?
|
|
@State private var artistCoverTargetId: String?
|
|
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
|
|
@ObservedObject private var libraryCache = LibraryCache.shared
|
|
|
|
// Song info / edit
|
|
@State private var getInfoSong: Song?
|
|
@State private var trackEditorSong: Song?
|
|
|
|
// Playlist rename
|
|
@State private var showRenamePlaylist = false
|
|
@State private var renamePlaylistId: String?
|
|
@State private var renamePlaylistName = ""
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
enum SortMode: String, CaseIterable {
|
|
case recentlyAdded = "Recently Added"
|
|
case favourites = "Favourites"
|
|
case artists = "Artists"
|
|
case albums = "Albums"
|
|
case songs = "Songs"
|
|
case genres = "Genres"
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Tab pills
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(SortMode.allCases, id: \.self) { mode in
|
|
Button(action: {
|
|
sortMode = mode
|
|
searchText = ""
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
if mode == .favourites {
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 11))
|
|
}
|
|
Text(mode == .favourites ? "Favourites" : mode.rawValue)
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
|
|
.foregroundColor(sortMode == mode ? .white : .gray)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// Search bar (for all tabs except Recently Added)
|
|
if sortMode != .recentlyAdded {
|
|
searchBar
|
|
}
|
|
|
|
// Content
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
switch sortMode {
|
|
case .recentlyAdded: recentlyAddedTab
|
|
case .favourites: favouritesTab
|
|
case .artists: artistsTab
|
|
case .albums: albumsTab
|
|
case .songs: songsTab
|
|
case .genres: genresTab
|
|
}
|
|
Color.clear.frame(height: 100)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(white: 0.06))
|
|
.navigationTitle("My Music")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
if isSelectingAlbums {
|
|
Button(action: {
|
|
isSelectingAlbums = false
|
|
selectedAlbumIds.removeAll()
|
|
}) {
|
|
Text("Cancel")
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .principal) {
|
|
if isSelectingAlbums {
|
|
Text("\(selectedAlbumIds.count) selected")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if isSelectingAlbums {
|
|
Button(action: { showBatchAlbumEditor = true }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "tag")
|
|
Text("Edit")
|
|
}
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
|
|
}
|
|
.disabled(selectedAlbumIds.isEmpty)
|
|
} else {
|
|
Button(action: { showServerPicker = true }) {
|
|
Image(systemName: "server.rack")
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showServerPicker) {
|
|
ServerPickerSheet(isPresented: $showServerPicker)
|
|
}
|
|
.alert("New Playlist", isPresented: $showCreatePlaylist) {
|
|
TextField("Playlist name", text: $newPlaylistName)
|
|
Button("Create") {
|
|
let name = newPlaylistName
|
|
newPlaylistName = ""
|
|
Task {
|
|
try? await serverManager.client.createPlaylist(name: name)
|
|
await loadData()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { newPlaylistName = "" }
|
|
}
|
|
.alert("Rename Playlist", isPresented: $showRenamePlaylist) {
|
|
TextField("Name", text: $renamePlaylistName)
|
|
Button("Save") {
|
|
let newName = renamePlaylistName
|
|
guard let pid = renamePlaylistId else { return }
|
|
Task {
|
|
try? await serverManager.client.updatePlaylist(id: pid, name: newName)
|
|
await loadData()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { }
|
|
}
|
|
.task { await loadData() }
|
|
.refreshable { await loadData() }
|
|
.sheet(isPresented: $showBatchAlbumEditor) {
|
|
MultiAlbumEditorSheet(albumIds: Array(selectedAlbumIds)) {
|
|
isSelectingAlbums = false
|
|
selectedAlbumIds.removeAll()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSingleAlbumEditor) {
|
|
if let albumId = singleEditAlbumId {
|
|
MultiAlbumEditorSheet(albumIds: [albumId])
|
|
}
|
|
}
|
|
.sheet(item: $getInfoSong) { song in
|
|
SongInfoSheet(song: song)
|
|
}
|
|
.sheet(item: $trackEditorSong) { song in
|
|
TrackEditorView(song: song)
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToPlaylistId != nil },
|
|
set: { if !$0 { navigateToPlaylistId = nil } }
|
|
)) {
|
|
if let pid = navigateToPlaylistId {
|
|
PlaylistDetailView(playlistId: pid)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToAlbumId != nil },
|
|
set: { if !$0 { navigateToAlbumId = nil } }
|
|
)) {
|
|
if let aid = navigateToAlbumId {
|
|
AlbumDetailView(albumId: aid)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { navigateToArtistId != nil },
|
|
set: { if !$0 { navigateToArtistId = nil } }
|
|
)) {
|
|
if let aid = navigateToArtistId {
|
|
ArtistDetailView(artistId: aid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Bar
|
|
|
|
private var searchBar: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(.gray)
|
|
.font(.system(size: 14))
|
|
TextField("Search \(sortMode.rawValue.lowercased())...", text: $searchText)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.white)
|
|
.autocorrectionDisabled()
|
|
.keyboardDoneButton()
|
|
if !searchText.isEmpty {
|
|
Button(action: { searchText = "" }) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.gray)
|
|
.font(.system(size: 14))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color.white.opacity(0.08))
|
|
.cornerRadius(10)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - Recently Added Tab
|
|
|
|
private var recentlyAddedTab: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
recentlyAddedSection
|
|
playlistsSection
|
|
}
|
|
}
|
|
|
|
private var recentlyAddedSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Recently Added")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
NavigationLink(destination: AlbumGridView(title: "Recently Added", type: "newest")) {
|
|
Text("more")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 14) {
|
|
ForEach(deduplicateAlbums(Array(recentAlbums.prefix(20)))) { album in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 160)
|
|
.frame(width: 160, height: 160)
|
|
.cornerRadius(4)
|
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
|
}
|
|
|
|
Button(action: { playAlbum(album) }) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 4)
|
|
}
|
|
.padding(6)
|
|
}
|
|
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(album.name)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
|
|
Text(album.artist ?? "")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 160)
|
|
.contextMenu {
|
|
Button(action: { playAlbum(album) }) {
|
|
Label("Play", systemImage: "play.fill")
|
|
}
|
|
Button(action: { playAlbumNext(album) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
Button(action: { playAlbumLater(album) }) {
|
|
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
Divider()
|
|
|
|
Button(action: {
|
|
singleEditAlbumId = album.id
|
|
showSingleAlbumEditor = true
|
|
}) {
|
|
Label("Edit Album", systemImage: "tag")
|
|
}
|
|
|
|
Button(action: {
|
|
selectedAlbumIds = [album.id]
|
|
isSelectingAlbums = true
|
|
sortMode = .albums
|
|
}) {
|
|
Label("Select Albums", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Playlists Section
|
|
|
|
private var playlistsSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Playlists")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
Button(action: { showCreatePlaylist = true }) {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 12)
|
|
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(playlists) { playlist in
|
|
NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: playlist.coverArt, size: 56)
|
|
.frame(width: 52, height: 52)
|
|
.cornerRadius(4)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(playlist.name)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text("\(playlist.songCount ?? 0) songs")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
renamePlaylistId = playlist.id
|
|
renamePlaylistName = playlist.name
|
|
showRenamePlaylist = true
|
|
}) {
|
|
Label("Rename Playlist", systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive, action: {
|
|
Task {
|
|
try? await serverManager.client.deletePlaylist(id: playlist.id)
|
|
await loadData()
|
|
}
|
|
}) {
|
|
Label("Delete Playlist", systemImage: "trash")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 86)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Favourites Tab
|
|
|
|
private var favouritesTab: some View {
|
|
let filtered = searchText.isEmpty ? favouriteSongs : favouriteSongs.filter {
|
|
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
return VStack(spacing: 0) {
|
|
// Header buttons
|
|
if !favouriteSongs.isEmpty {
|
|
HStack(spacing: 12) {
|
|
Button(action: downloadAllFavourites) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.down.circle")
|
|
Text("Download All")
|
|
}
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(accentPink.opacity(0.15))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
if WatchConnectivityManager.shared.isWatchAvailable {
|
|
Button(action: sendAllFavouritesToWatch) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "applewatch.and.arrow.forward")
|
|
Text("Send to Watch")
|
|
}
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(accentPink.opacity(0.15))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(favouriteSongs.count) songs")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// Song list with swipe-to-delete
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
|
|
songRow(song: song, index: index, allSongs: filtered)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
removeFavourite(song)
|
|
} label: {
|
|
Label("Unfavourite", systemImage: "heart.slash")
|
|
}
|
|
.tint(.red)
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.leading, 72)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState(searchText.isEmpty ? "No favourites yet — star songs to see them here" : "No matches")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
private func removeFavourite(_ song: Song) {
|
|
Task {
|
|
try? await serverManager.client.unstar(id: song.id)
|
|
await MainActor.run {
|
|
favouriteSongs.removeAll { $0.id == song.id }
|
|
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func downloadAllFavourites() {
|
|
guard let server = serverManager.activeServer else { return }
|
|
for song in favouriteSongs {
|
|
if !offlineManager.isSongDownloaded(song.id) {
|
|
offlineManager.downloadSong(song, server: server)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendAllFavouritesToWatch() {
|
|
for song in favouriteSongs {
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
|
}
|
|
}
|
|
|
|
// MARK: - Artists Tab
|
|
|
|
private var artistsTab: some View {
|
|
let flatArtists = artists.flatMap { $0.artist ?? [] }
|
|
let filtered = searchText.isEmpty ? flatArtists : flatArtists.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
return LazyVStack(spacing: 0) {
|
|
ForEach(filtered) { artist in
|
|
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
|
|
HStack(spacing: 14) {
|
|
if let customImage = artistCoverStore.loadCover(for: artist.id) {
|
|
Image(uiImage: customImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 48, height: 48)
|
|
.clipShape(Circle())
|
|
} else {
|
|
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
|
|
.frame(width: 48, height: 48)
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
Text(artist.name)
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
|
|
Spacer()
|
|
|
|
if let count = artist.albumCount {
|
|
Text("\(count) albums")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
artistCoverTargetId = artist.id
|
|
showArtistCoverPicker = true
|
|
}) {
|
|
Label("Change Artist Cover", systemImage: "photo.on.rectangle")
|
|
}
|
|
|
|
if artistCoverStore.hasCover(for: artist.id) {
|
|
Button(role: .destructive, action: {
|
|
artistCoverStore.removeCover(for: artist.id)
|
|
}) {
|
|
Label("Remove Custom Cover", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.1))
|
|
.padding(.leading, 78)
|
|
}
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No artists found")
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
.photosPicker(
|
|
isPresented: $showArtistCoverPicker,
|
|
selection: $artistCoverPickerItem,
|
|
matching: .images
|
|
)
|
|
.onChange(of: artistCoverPickerItem) { _, newItem in
|
|
guard let item = newItem, let targetId = artistCoverTargetId else { return }
|
|
Task {
|
|
if let data = try? await item.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
artistCoverStore.saveCover(image, for: targetId)
|
|
}
|
|
artistCoverPickerItem = nil
|
|
artistCoverTargetId = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Albums Tab (grid with selection)
|
|
|
|
private var albumsTab: some View {
|
|
let deduped = deduplicateAlbums(allAlbums)
|
|
let filtered = searchText.isEmpty ? deduped : deduped.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
|
|
let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
|
|
|
|
return VStack(spacing: 0) {
|
|
// Quick actions bar in selection mode
|
|
if isSelectingAlbums {
|
|
HStack(spacing: 16) {
|
|
Button(action: {
|
|
if selectedAlbumIds.count == filtered.count {
|
|
selectedAlbumIds.removeAll()
|
|
} else {
|
|
selectedAlbumIds = Set(filtered.map { $0.id })
|
|
}
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: selectedAlbumIds.count == filtered.count ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 14))
|
|
Text(selectedAlbumIds.count == filtered.count ? "Deselect All" : "Select All")
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
.foregroundColor(accentPink)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Color.white.opacity(0.03))
|
|
}
|
|
|
|
LazyVGrid(columns: columns, spacing: 18) {
|
|
ForEach(filtered) { album in
|
|
if isSelectingAlbums {
|
|
// Selection mode: tap to toggle selection
|
|
albumTile(album)
|
|
.overlay(alignment: .topLeading) {
|
|
Image(systemName: selectedAlbumIds.contains(album.id) ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(selectedAlbumIds.contains(album.id) ? accentPink : .white.opacity(0.5))
|
|
.shadow(radius: 4)
|
|
.padding(6)
|
|
}
|
|
.opacity(selectedAlbumIds.isEmpty || selectedAlbumIds.contains(album.id) ? 1 : 0.5)
|
|
.onTapGesture {
|
|
if selectedAlbumIds.contains(album.id) {
|
|
selectedAlbumIds.remove(album.id)
|
|
} else {
|
|
selectedAlbumIds.insert(album.id)
|
|
}
|
|
}
|
|
} else {
|
|
// Normal mode: navigate + context menu
|
|
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
|
albumTile(album)
|
|
}
|
|
.contextMenu {
|
|
Button(action: { playAlbum(album) }) {
|
|
Label("Play", systemImage: "play.fill")
|
|
}
|
|
Button(action: { playAlbumNext(album) }) {
|
|
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
|
}
|
|
Button(action: { playAlbumLater(album) }) {
|
|
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
Divider()
|
|
|
|
Button(action: {
|
|
singleEditAlbumId = album.id
|
|
showSingleAlbumEditor = true
|
|
}) {
|
|
Label("Edit Album", systemImage: "tag")
|
|
}
|
|
|
|
Button(action: {
|
|
selectedAlbumIds = [album.id]
|
|
isSelectingAlbums = true
|
|
}) {
|
|
Label("Select Albums", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
|
|
if filtered.isEmpty && !isLoading {
|
|
emptyState("No albums found")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reusable album tile (cover + title + artist)
|
|
private func albumTile(_ album: Album) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
|
|
.frame(height: 160)
|
|
.cornerRadius(4)
|
|
|
|
Text(album.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
|
|
Text(album.artist ?? "")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Songs Tab (list)
|
|
|
|
/// First letter of a song title for section headers, collapsing digits/symbols to "#"
|
|
private func sectionKey(for song: Song) -> String {
|
|
let first = song.title.first ?? "#"
|
|
return first.isLetter ? String(first).uppercased() : "#"
|
|
}
|
|
|
|
private var songsTab: some View {
|
|
let filtered: [Song] = {
|
|
let base = allSongs
|
|
guard !searchText.isEmpty else { return base }
|
|
return base.filter {
|
|
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.artist ?? "").localizedCaseInsensitiveContains(searchText) ||
|
|
($0.album ?? "").localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}()
|
|
|
|
// Group into alphabetical sections
|
|
let grouped: [(String, [Song])] = {
|
|
var dict: [String: [Song]] = [:]
|
|
for song in filtered {
|
|
let key = sectionKey(for: song)
|
|
dict[key, default: []].append(song)
|
|
}
|
|
// Letters A-Z then # for digits/symbols
|
|
let letters = dict.keys.filter { $0 != "#" }.sorted()
|
|
let keys = dict["#"] != nil ? letters + ["#"] : letters
|
|
return keys.compactMap { k in dict[k].map { (k, $0) } }
|
|
}()
|
|
|
|
return LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
|
|
if isLoadingSongs {
|
|
ProgressView()
|
|
.tint(accentPink)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 60)
|
|
} else if filtered.isEmpty {
|
|
emptyState("No songs found")
|
|
} else {
|
|
ForEach(grouped, id: \.0) { (letter, songs) in
|
|
Section {
|
|
ForEach(Array(songs.enumerated()), id: \.element.id) { idx, song in
|
|
songRow(song: song, index: idx, allSongs: Array(filtered))
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.leading, 72)
|
|
}
|
|
} header: {
|
|
Text(letter)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.gray)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
.background(Color.black.opacity(0.85))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
.task {
|
|
guard !songsLoaded, !isLoadingSongs else { return }
|
|
await loadAllSongs()
|
|
}
|
|
}
|
|
|
|
private func loadAllSongs() async {
|
|
// Cache hit → instant display
|
|
if let cached = LibraryCache.shared.load([Song].self, key: "all_songs_sorted") {
|
|
allSongs = cached
|
|
songsLoaded = true
|
|
return
|
|
}
|
|
isLoadingSongs = true
|
|
do {
|
|
var collected: [Song] = []
|
|
var offset = 0
|
|
while true {
|
|
let result = try await serverManager.client.search3(
|
|
query: "", artistCount: 0, artistOffset: 0,
|
|
albumCount: 0, albumOffset: 0,
|
|
songCount: 500, songOffset: offset)
|
|
let page = result?.song ?? []
|
|
collected.append(contentsOf: page)
|
|
if page.count < 500 { break }
|
|
offset += 500
|
|
}
|
|
let sorted = collected.sorted {
|
|
$0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending
|
|
}
|
|
LibraryCache.shared.save(sorted, key: "all_songs_sorted")
|
|
allSongs = sorted
|
|
songsLoaded = true
|
|
isLoadingSongs = false
|
|
} catch {
|
|
isLoadingSongs = false
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|