NavidromeApp/iOS/Views/Library/PlaylistsView.swift
Dallas Groot 2bdac607b4 bug fixes
Songs Tab (SearchView.swift)
Default state now loads all songs alphabetically from the library via
getAlbumList2 → per-album song fetch, cached under "all_songs_sorted"
so subsequent opens are instant. The Download All banner shows song
count + already-downloaded count and queues only non-downloaded songs.
Every row uses .contextMenu (the long-press menu) with Play Now, Play
Next, Add to Queue, Download/Remove, Send to Watch, and Add to
Playlist — same pattern as Favourites. Watch and download badges
appear on each row. Searching ≥2 chars runs the server search and
shows artists/albums/songs in sections, then clears back to the full
list when the field is empty.
Keyboard Done Button
A single keyboardDoneButton() View extension in AsyncCoverArt.swift
calls UIApplication.shared.sendAction(resignFirstResponder:...)
globally — no @FocusState needed. Applied to: LoginView (all 4
fields), CompanionSettingsView (host/port), TrackEditorView
(checkField helper covers all tag fields), BatchAlbumEditorSheet
(editField helper), RadioView (name/URL), PlaylistsView (name fields),
MyMusicView (search), SearchView (via @FocusState + toolbar directly).
ShazamKit MTAudioProcessingTap
Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix
— works for HLS, radio, and any AVPlayer stream without touching the
microphone. The prepare callback captures the source format and builds
an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free
function (required by the API) calls
MTAudioProcessingTapGetSourceAudio then dispatches to a serial
analysisQueue — the render thread is never blocked. convertAndMatch
wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and
feeds SHSession.matchStreamingBuffer. Fallback to microphone
(AVAudioEngine) is kept for the local engine path where no
AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if
the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00

541 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)
.keyboardDoneButton()
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.isServerAvailable
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")
}
}
}
}