import SwiftUI struct PlaylistsView: View { @EnvironmentObject var serverManager: ServerManager @EnvironmentObject var audioPlayer: AudioPlayer @State private var playlists: [Playlist] = [] @State private var isLoading = true @State private var showCreatePlaylist = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { NavigationStack { List { if isLoading { HStack { Spacer() ProgressView().tint(accentPink) Spacer() } .listRowBackground(Color.clear) } else if playlists.isEmpty { VStack(spacing: 12) { Image(systemName: "music.note.list") .font(.system(size: 40)) .foregroundColor(.gray) Text("No Playlists") .font(.system(size: 16)) .foregroundColor(.gray) } .frame(maxWidth: .infinity) .padding(.top, 60) .listRowBackground(Color.clear) } else { ForEach(playlists) { playlist in NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) { HStack(spacing: 14) { AsyncCoverArt( coverArtId: playlist.coverArt, size: 60 ) .frame(width: 56, height: 56) .cornerRadius(4) VStack(alignment: .leading, spacing: 3) { Text(playlist.name) .font(.system(size: 16, weight: .medium)) .foregroundColor(.white) Text("\(playlist.songCount ?? 0) songs") .font(.system(size: 12)) .foregroundColor(.gray) } } .padding(.vertical, 4) } } .onDelete(perform: deletePlaylists) } } .navigationTitle("Playlists") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { showCreatePlaylist = true }) { Image(systemName: "plus") .foregroundColor(accentPink) } } } .alert("New Playlist", isPresented: $showCreatePlaylist) { CreatePlaylistAlert() } .task { await loadPlaylists() } .refreshable { await loadPlaylists() } } } private func loadPlaylists() async { do { let result = try await serverManager.client.getPlaylists() await MainActor.run { playlists = result isLoading = false } } catch { await MainActor.run { isLoading = false } } } private func deletePlaylists(at offsets: IndexSet) { for idx in offsets { let playlist = playlists[idx] Task { try? await serverManager.client.deletePlaylist(id: playlist.id) } } playlists.remove(atOffsets: offsets) } } // MARK: - Create Playlist Alert struct CreatePlaylistAlert: View { @EnvironmentObject var serverManager: ServerManager @State private var name = "" var body: some View { TextField("Playlist name", text: $name) Button("Create") { Task { try? await serverManager.client.createPlaylist(name: name) } } Button("Cancel", role: .cancel) { } } } // MARK: - Playlist Detail View struct PlaylistDetailView: View { @EnvironmentObject var serverManager: ServerManager @EnvironmentObject var audioPlayer: AudioPlayer @EnvironmentObject var offlineManager: OfflineManager @ObservedObject private var watchManager = WatchConnectivityManager.shared @ObservedObject private var libraryCache = LibraryCache.shared let playlistId: String @State private var playlist: PlaylistWithSongs? @State private var isLoading = true @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? private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { ScrollView { if let playlist = playlist { VStack(spacing: 0) { playlistHeader(playlist) Divider().background(Color.white.opacity(0.1)) playlistSongList(playlist) Color.clear.frame(height: 120) } } 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) } } .task { do { playlist = try await serverManager.client.getPlaylist(id: playlistId) isLoading = false } catch { isLoading = false } } } @ViewBuilder private func playlistHeader(_ playlist: PlaylistWithSongs) -> some View { VStack(spacing: 12) { AsyncCoverArt(coverArtId: playlist.coverArt, size: 220) .frame(width: 180, height: 180) .cornerRadius(6) .shadow(color: .black.opacity(0.4), radius: 12) .padding(.top, 20) Text(playlist.name) .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) Text("\(playlist.songCount ?? 0) songs") .font(.system(size: 14)) .foregroundColor(.gray) HStack(spacing: 16) { Button(action: { playPlaylist(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) } Button(action: { playPlaylist(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) } Button(action: { downloadPlaylist() }) { Image(systemName: isPlaylistDownloaded() ? "checkmark.circle.fill" : "arrow.down.circle") .font(.system(size: 22)) .foregroundColor(isPlaylistDownloaded() ? .green : .white) .frame(width: 44, height: 38) .background(Color.white.opacity(0.12)) .cornerRadius(8) } if WatchConnectivityManager.shared.isWatchAvailable { Button(action: { sendPlaylistToWatch() }) { Image(systemName: "applewatch") .font(.system(size: 18)) .foregroundColor(isPlaylistOnWatch() ? .green : accentPink.opacity(0.6)) .frame(width: 44, height: 38) .background(Color.white.opacity(0.12)) .cornerRadius(8) } } } .padding(.horizontal, 20) } .padding(.bottom, 16) } @ViewBuilder private func playlistSongList(_ playlist: PlaylistWithSongs) -> some View { let songs = playlist.entry ?? [] LazyVStack(spacing: 0) { ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in playlistSongRow(song: song, index: index, songs: songs) } } } private func playPlaylist(shuffle: Bool) { guard let songs = playlist?.entry, !songs.isEmpty else { return } if shuffle { audioPlayer.shuffleEnabled = true } audioPlayer.play(song: songs[0], fromQueue: songs, playlistId: playlistId, playlistName: playlist?.name) } private func downloadPlaylist() { guard let playlist = playlist, let server = serverManager.activeServer else { return } offlineManager.downloadPlaylist(playlist, server: server) } private func isPlaylistDownloaded() -> Bool { guard let songs = playlist?.entry, !songs.isEmpty else { return false } return songs.allSatisfy { offlineManager.isSongDownloaded($0.id) } } private func isPlaylistOnWatch() -> Bool { guard let songs = playlist?.entry, !songs.isEmpty else { return false } return songs.allSatisfy { WatchConnectivityManager.shared.isSongOnWatch($0.id) } } private func sendPlaylistToWatch() { guard let songs = playlist?.entry else { return } for song in songs { if offlineManager.isSongDownloaded(song.id) { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } else if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } } } @ViewBuilder private func playlistSongRow(song: Song, index: Int, songs: [Song]) -> some View { let dlState = offlineManager.downloads[song.id] let isDownloaded = offlineManager.isSongDownloaded(song.id) let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) let available = isDownloaded || !libraryCache.isOffline Button(action: { if available { audioPlayer.play(song: song, fromQueue: songs, at: index, playlistId: playlistId, playlistName: playlist?.name) } }) { 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: 3) { Text(song.title) .font(.system(size: 15)) .foregroundColor( !available ? .gray.opacity(0.35) : audioPlayer.currentSong?.id == song.id ? accentPink : .white ) .lineLimit(1) HStack(spacing: 4) { Text(song.artist ?? "") .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 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)) } } Text(song.durationFormatted) .font(.system(size: 13)) .foregroundColor(.gray) } .padding(.horizontal, 16) .padding(.vertical, 8) } .contextMenu { songContextMenu(song: song, index: index) } Divider() .background(Color.white.opacity(0.08)) .padding(.leading, 72) } @ViewBuilder private func songContextMenu(song: Song, index: Int) -> some View { 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() Button(role: .destructive, action: { Task { try? await serverManager.client.updatePlaylist(id: playlistId, songIndexesToRemove: [index]) playlist = try? await serverManager.client.getPlaylist(id: playlistId) } }) { Label("Remove from Playlist", systemImage: "minus.circle") } 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: { getInfoSong = song; showGetInfo = true }) { Label("Get Info", systemImage: "info.circle") } } }