639 lines
26 KiB
Swift
639 lines
26 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 showGetInfo = false
|
||
|
|
@State private var getInfoSong: Song?
|
||
|
|
@State private var showCoverPicker = false
|
||
|
|
@State private var selectedCoverPhoto: PhotosPickerItem?
|
||
|
|
@State private var showTrackEditor = false
|
||
|
|
@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(isPresented: $showGetInfo) {
|
||
|
|
if let song = getInfoSong {
|
||
|
|
SongInfoSheet(song: song)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.sheet(isPresented: $showTrackEditor) {
|
||
|
|
if let song = trackEditorSong {
|
||
|
|
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 WatchConnectivityManager.shared.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 = WatchConnectivityManager.shared.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")
|
||
|
|
}
|
||
|
|
|
||
|
|
if WatchConnectivityManager.shared.isWatchAvailable {
|
||
|
|
Button(action: {
|
||
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
||
|
|
}) {
|
||
|
|
Label("Send to Watch", systemImage: "applewatch.and.arrow.forward")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Button(action: {
|
||
|
|
if let server = serverManager.activeServer {
|
||
|
|
offlineManager.downloadSong(song, server: server)
|
||
|
|
}
|
||
|
|
}) {
|
||
|
|
Label("Download", systemImage: "arrow.down.circle")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Button(action: {
|
||
|
|
Task {
|
||
|
|
if song.starred != nil {
|
||
|
|
try? await serverManager.client.unstar(id: song.id)
|
||
|
|
} else {
|
||
|
|
try? await serverManager.client.star(id: song.id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}) {
|
||
|
|
Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart")
|
||
|
|
}
|
||
|
|
|
||
|
|
Divider()
|
||
|
|
|
||
|
|
Button(action: {
|
||
|
|
getInfoSong = song
|
||
|
|
showGetInfo = true
|
||
|
|
}) {
|
||
|
|
Label("Get Info", systemImage: "info.circle")
|
||
|
|
}
|
||
|
|
|
||
|
|
if CompanionSettings.shared.isEnabled {
|
||
|
|
Button(action: {
|
||
|
|
trackEditorSong = song
|
||
|
|
showTrackEditor = true
|
||
|
|
}) {
|
||
|
|
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 { WatchConnectivityManager.shared.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) {
|
||
|
|
_ = WatchConnectivityManager.shared.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) {
|
||
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func loadAlbum() async {
|
||
|
|
let cache = LibraryCache.shared
|
||
|
|
loadFailed = false
|
||
|
|
|
||
|
|
// 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 }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 online)
|
||
|
|
private func isSongAvailable(_ song: Song) -> Bool {
|
||
|
|
if offlineManager.isSongDownloaded(song.id) { return true }
|
||
|
|
return !libraryCache.isOffline
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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,
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|