NavidromeApp/iOS/Views/Library/AlbumDetailView.swift
Dallas Groot 0730fa11f8 bug fixes
2026-04-11 15:09:06 -07:00

697 lines
29 KiB
Swift

import SwiftUI
import PhotosUI
struct AlbumDetailView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@ObservedObject private var albumCoverStore = AlbumCoverStore.shared
@ObservedObject private var libraryCache = LibraryCache.shared
let albumId: String
@State private var album: AlbumWithSongs?
@State private var isLoading = true
@State private var isDownloading = false
@State private var showPlaylistPicker = false
@State private var playlistPickerSongId: String?
@State private var availablePlaylists: [Playlist] = []
@State private var getInfoSong: Song?
@State private var showCoverPicker = false
@State private var selectedCoverPhoto: PhotosPickerItem?
@State private var trackEditorSong: Song?
@State private var showBatchEditor = false
@State private var loadFailed = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
ScrollView {
if let album = album {
VStack(spacing: 0) {
// Album header - iOS 8 style
albumHeader(album)
// Song list
songList(album)
// Bottom spacing
Color.clear.frame(height: 120)
}
} else if loadFailed {
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load album")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadAlbum() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(accentPink)
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
.sheet(item: $getInfoSong) { song in
SongInfoSheet(song: song)
}
.sheet(item: $trackEditorSong) { song in
TrackEditorView(song: song)
}
.sheet(isPresented: $showBatchEditor) {
if let album = album {
BatchAlbumEditorSheet(album: album)
}
}
.photosPicker(
isPresented: $showCoverPicker,
selection: $selectedCoverPhoto,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: selectedCoverPhoto) { _, newItem in
guard let item = newItem, let coverArtId = album?.coverArt else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
let resized = resizeAlbumCover(image, maxSize: 600)
albumCoverStore.saveCover(resized, for: coverArtId)
}
selectedCoverPhoto = nil
}
}
.task {
await loadAlbum()
}
}
// MARK: - Album Header
private func albumHeader(_ album: AlbumWithSongs) -> some View {
VStack(spacing: 12) {
// Album art long press for cover options
AsyncCoverArt(
coverArtId: album.coverArt,
size: 260
)
.frame(width: 220, height: 220)
.cornerRadius(6)
.shadow(color: .black.opacity(0.4), radius: 12, y: 6)
.padding(.top, 20)
.contextMenu {
Button(action: { showCoverPicker = true }) {
Label("Choose Cover Art...", systemImage: "photo.on.rectangle")
}
if CompanionSettings.shared.isEnabled {
Button(action: { showBatchEditor = true }) {
Label("Edit Album Tags...", systemImage: "tag")
}
}
if let coverArtId = album.coverArt, albumCoverStore.hasCover(for: coverArtId) {
Button(role: .destructive, action: {
albumCoverStore.removeCover(for: coverArtId)
}) {
Label("Restore Original Cover", systemImage: "arrow.uturn.backward")
}
}
}
// Album info
Text(album.name)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.multilineTextAlignment(.center)
Text(album.artist ?? "Unknown Artist")
.font(.system(size: 15))
.foregroundColor(accentPink)
HStack(spacing: 4) {
Text(album.genre ?? "")
if album.year != nil {
Text("")
Text(String(album.year!))
}
}
.font(.system(size: 13))
.foregroundColor(.gray)
// Action buttons row
HStack(spacing: 10) {
// Play button
Button(action: { playAlbum(album, shuffle: false) }) {
HStack(spacing: 6) {
Image(systemName: "play.fill")
Text("Play")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(accentPink)
.foregroundColor(.white)
.cornerRadius(8)
}
// Shuffle button
Button(action: { playAlbum(album, shuffle: true) }) {
HStack(spacing: 6) {
Image(systemName: "shuffle")
Text("Shuffle")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.12))
.foregroundColor(.white)
.cornerRadius(8)
}
// Download button (red = not downloaded, green = downloaded)
Button(action: { downloadAlbum(album) }) {
Image(systemName: isAlbumDownloaded(album) ? "checkmark.circle.fill" : "arrow.down.circle")
.font(.system(size: 20))
.foregroundColor(isAlbumDownloaded(album) ? .green : .red)
}
.frame(width: 38, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
// Watch button (red = not synced, green = synced)
if watchManager.isWatchAvailable {
Button(action: { sendAlbumToWatch(album) }) {
Image(systemName: "applewatch")
.font(.system(size: 17))
.foregroundColor(isAlbumOnWatch(album) ? .green : .red)
}
.frame(width: 38, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
Divider()
.background(Color.white.opacity(0.1))
.padding(.top, 12)
}
}
// MARK: - Song List
private func songList(_ album: AlbumWithSongs) -> some View {
LazyVStack(spacing: 0) {
ForEach(Array((album.song ?? []).enumerated()), id: \.element.id) { index, song in
let available = isSongAvailable(song)
let dlState = offlineManager.downloads[song.id]
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = watchManager.isSongOnWatch(song.id)
HStack(spacing: 14) {
// Track number with download overlay
ZStack {
Text("\(song.track ?? (index + 1))")
.font(.system(size: 15))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
if case .downloading(let progress) = dlState {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 2)
.frame(width: 22, height: 22)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.frame(width: 22, height: 22)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
Circle()
.stroke(accentPink.opacity(0.3), lineWidth: 2)
.frame(width: 22, height: 22)
}
}
.frame(width: 24, alignment: .center)
// Song title + progress bar (tappable area)
VStack(alignment: .leading, spacing: 3) {
Text(song.title)
.font(.system(size: 16))
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
if let artist = song.artist, artist != album.artist {
Text(artist)
.font(.system(size: 12))
.foregroundColor(.gray)
}
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)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if available { playSong(song, from: album) }
}
// Status indicators
HStack(spacing: 6) {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 10))
.foregroundColor(.blue.opacity(0.7))
}
if isDownloaded {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 12))
.foregroundColor(.green.opacity(0.6))
}
}
// Duration
Text(song.durationFormatted)
.font(.system(size: 14))
.foregroundColor(.gray)
// More button standalone Menu, NOT inside a Button
Menu {
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")
}
Button(action: {
playlistPickerSongId = song.id
Task {
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
showPlaylistPicker = true
}
}) {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
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")
}
}
// Send to Watch always visible
let isOnWatch = watchManager.isSongOnWatch(song.id)
Button(action: {
if !isOnWatch { _ = watchManager.sendSongToWatch(song) }
}) {
Label(isOnWatch ? "On Watch ✓" : "Send to Watch",
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward")
}
.disabled(isOnWatch)
// Favourite routes through OptimisticActionQueue (AUDIT-057)
Button(action: {
if song.starred != nil {
OptimisticActionQueue.shared.unstar(songId: song.id)
} else {
OptimisticActionQueue.shared.star(songId: song.id)
}
}) {
Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
}
Divider()
Button(action: {
getInfoSong = song
}) {
Label("Get Info", systemImage: "info.circle")
}
if CompanionSettings.shared.isEnabled {
Button(action: {
trackEditorSong = song
}) {
Label("Edit Tags", systemImage: "tag")
}
}
} label: {
Image(systemName: "ellipsis")
.foregroundColor(.gray)
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if index < (album.song?.count ?? 0) - 1 {
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 54)
}
}
}
}
// MARK: - Actions
private func playAlbum(_ album: AlbumWithSongs, shuffle: Bool) {
guard let songs = album.song, !songs.isEmpty else { return }
if shuffle { audioPlayer.shuffleEnabled = true }
audioPlayer.play(song: songs[0], fromQueue: songs)
}
private func playSong(_ song: Song, from album: AlbumWithSongs) {
guard let songs = album.song else { return }
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
private func downloadAlbum(_ album: AlbumWithSongs) {
guard let server = serverManager.activeServer else { return }
offlineManager.downloadAlbum(album, server: server)
}
private func isAlbumDownloaded(_ album: AlbumWithSongs) -> Bool {
guard let songs = album.song else { return false }
return songs.allSatisfy { offlineManager.isSongDownloaded($0.id) }
}
private func isAlbumOnWatch(_ album: AlbumWithSongs) -> Bool {
guard let songs = album.song, !songs.isEmpty else { return false }
return songs.allSatisfy { watchManager.isSongOnWatch($0.id) }
}
private func sendAlbumToWatch(_ album: AlbumWithSongs) {
guard let songs = album.song else { return }
// Download first if needed, then send each song
for song in songs {
if offlineManager.isSongDownloaded(song.id) {
_ = watchManager.sendSongToWatch(song)
} else if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
// Queue watch send after download completes
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self.offlineManager.isSongDownloaded(song.id) {
_ = watchManager.sendSongToWatch(song)
}
}
}
}
}
private func loadAlbum() async {
let cache = LibraryCache.shared
loadFailed = false
// Companion album IDs are prefixed "companion:{name}|{artist}"
if albumId.hasPrefix("companion:") {
await loadCompanionAlbum()
return
}
// 1. Show cached version instantly no spinner if we have data
if album == nil, let cached = cache.loadAlbumDetail(id: albumId) {
album = cached
isLoading = false
}
// 2. Fetch from server in background
do {
if let result = try await serverManager.client.getAlbum(id: albumId) {
cache.cacheAlbumDetail(result)
await MainActor.run {
self.album = result
self.isLoading = false
}
} else if album == nil {
await retryAlbumLoad()
} else {
await MainActor.run { self.isLoading = false }
}
} catch {
if album == nil {
await retryAlbumLoad()
} else {
await MainActor.run { self.isLoading = false }
}
}
}
/// Load album detail from Companion API for companion-sourced albums.
/// "companion:{albumName}|{albumArtist}" fetch songs via /library/songs?album=...
private func loadCompanionAlbum() async {
// Parse "companion:{name}|{artist}" from albumId
let payload = String(albumId.dropFirst("companion:".count))
let parts = payload.components(separatedBy: "|")
let albumName = parts.first ?? payload
let albumArtist = parts.count > 1 ? parts[1] : ""
// Check local cache first
if let cached = LibraryCache.shared.loadCompanionAlbumSongs(albumId: albumId) {
let builtAlbum = buildAlbumWithSongs(name: albumName, artist: albumArtist, songs: cached)
await MainActor.run {
self.album = builtAlbum
self.isLoading = false
}
// Still refresh in background
}
do {
let companionSongs = try await CompanionAPIService.shared
.fetchAlbumSongs(album: albumName, albumArtist: albumArtist)
LibraryCache.shared.cacheCompanionAlbumSongs(companionSongs, albumId: albumId)
let builtAlbum = buildAlbumWithSongs(name: albumName, artist: albumArtist, songs: companionSongs)
await MainActor.run {
self.album = builtAlbum
self.isLoading = false
}
} catch {
await MainActor.run {
if self.album == nil { self.loadFailed = true }
self.isLoading = false
}
}
}
private func buildAlbumWithSongs(name: String, artist: String, songs: [CompanionSong]) -> AlbumWithSongs {
let stdSongs = songs.map { $0.toSong() }
let coverArt = songs.first.map { "companion:\($0.id)" }
let totalDuration = stdSongs.compactMap { $0.duration }.reduce(0, +)
let albumArtist = songs.first?.album_artist ?? artist
return AlbumWithSongs(
id: albumId,
name: name,
artist: artist,
artistId: nil,
albumArtist: albumArtist,
coverArt: coverArt,
songCount: songs.count,
duration: totalDuration > 0 ? totalDuration : nil,
playCount: nil,
created: nil,
starred: nil,
year: songs.first?.year,
genre: songs.first?.genre,
song: stdSongs
)
}
private func retryAlbumLoad() async {
// Wait for server connection to establish
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
do {
if let result = try await serverManager.client.getAlbum(id: albumId) {
LibraryCache.shared.cacheAlbumDetail(result)
await MainActor.run {
self.album = result
self.isLoading = false
}
return
}
} catch { }
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
/// Whether a song is available (downloaded or server reachable)
private func isSongAvailable(_ song: Song) -> Bool {
if offlineManager.isSongDownloaded(song.id) { return true }
return libraryCache.isServerAvailable
}
/// Resize image to max dimension while preserving aspect ratio
private func resizeAlbumCover(_ image: UIImage, maxSize: CGFloat) -> UIImage {
let size = image.size
let scale = min(maxSize / size.width, maxSize / size.height)
guard scale < 1 else { return image }
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
// MARK: - Album Grid View (for "more" navigation)
struct AlbumGridView: View {
@EnvironmentObject var serverManager: ServerManager
let title: String
let type: String
var genre: String? = nil
@State private var albums: [Album] = []
@State private var isLoading = true
@State private var searchText = ""
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 14)
]
private var filtered: [Album] {
let deduped = deduplicateAlbums(albums)
return searchText.isEmpty ? deduped : deduped.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
}
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)
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
if sameAlbum.count > 1 {
result.append(Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
albumArtist: "Various Artists",
coverArt: album.coverArt, songCount: album.songCount,
duration: album.duration, playCount: album.playCount,
created: album.created, starred: album.starred,
year: album.year, genre: album.genre
))
} else {
result.append(album)
}
}
return result
}
var body: some View {
VStack(spacing: 0) {
// Search
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 14))
TextField("Search albums...", 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(.vertical, 8)
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(filtered) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
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)
}
}
}
}
.padding(16)
}
}
.background(Color(white: 0.06))
.navigationTitle(title)
.task {
do {
let result = try await serverManager.client.getAlbumList2(type: type, size: 200, genre: genre)
await MainActor.run {
albums = result
isLoading = false
}
} catch {
isLoading = false
}
}
}
}