540 lines
21 KiB
Swift
540 lines
21 KiB
Swift
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 getInfoSong: Song?
|
|
@State private var trackEditorSong: Song?
|
|
@State private var isReordering = false
|
|
@State private var reorderError = false
|
|
|
|
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)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if playlist?.entry?.isEmpty == false {
|
|
Button(isReordering ? "Done" : "Reorder") {
|
|
withAnimation { isReordering.toggle() }
|
|
}
|
|
.foregroundColor(isReordering ? accentPink : .white)
|
|
.font(.system(size: 14, weight: isReordering ? .semibold : .regular))
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showPlaylistPicker) {
|
|
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
|
|
}
|
|
.sheet(item: $getInfoSong) { song in
|
|
SongInfoSheet(song: song)
|
|
}
|
|
.sheet(item: $trackEditorSong) { song in
|
|
TrackEditorView(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 ?? []
|
|
if isReordering {
|
|
// Reorder mode: List with drag handles, dark styled to match the rest of the view
|
|
List {
|
|
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
|
|
reorderSongRow(song: song)
|
|
.listRowBackground(Color(white: 0.06))
|
|
.listRowSeparatorTint(Color.white.opacity(0.08))
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
|
}
|
|
.onMove { from, to in
|
|
guard var entries = self.playlist?.entry else { return }
|
|
entries.move(fromOffsets: from, toOffset: to)
|
|
self.playlist?.entry = entries
|
|
// Sync to server in background — optimistic local update above is instant
|
|
Task {
|
|
do {
|
|
try await serverManager.client.reorderPlaylist(id: playlistId, songs: entries)
|
|
} catch {
|
|
// Revert on failure by refreshing from server
|
|
self.playlist = try? await serverManager.client.getPlaylist(id: playlistId)
|
|
reorderError = true
|
|
}
|
|
}
|
|
}
|
|
// Bottom padding row
|
|
Color.clear.frame(height: 80)
|
|
.listRowBackground(Color(white: 0.06))
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(white: 0.06))
|
|
.environment(\.editMode, .constant(.active))
|
|
.frame(minHeight: CGFloat(songs.count) * 60 + 80)
|
|
} else {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
|
|
playlistSongRow(song: song, index: index, songs: songs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Minimal row shown in reorder mode — just art, title, artist, drag handle cue
|
|
@ViewBuilder
|
|
private func reorderSongRow(song: Song) -> some View {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
|
|
.frame(width: 36, height: 36)
|
|
.cornerRadius(3)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(song.title)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text(song.artist ?? "")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Text(song.durationFormatted)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray.opacity(0.6))
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
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 {
|
|
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
|
|
|
|
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()
|
|
|
|
// Favourite
|
|
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 ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Remove from playlist (only in playlist context)
|
|
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")
|
|
}
|
|
} 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: {
|
|
if !isOnWatch { _ = 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")
|
|
}
|
|
}
|
|
}
|
|
}
|