NavidromeApp/iOS/Views/Library/PlaylistsView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

431 lines
17 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 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")
}
}
}