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.
541 lines
21 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|
|
}
|