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.
359 lines
14 KiB
Swift
359 lines
14 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
import UIKit
|
|
|
|
// MARK: - Radio Cover Store (disk-persisted per station)
|
|
|
|
class RadioCoverStore: ObservableObject {
|
|
static let shared = RadioCoverStore()
|
|
|
|
/// Triggers SwiftUI refresh when a cover changes
|
|
@Published var updateTrigger = UUID()
|
|
|
|
private let fileManager = FileManager.default
|
|
private let coverDir: URL
|
|
|
|
private init() {
|
|
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
coverDir = docs.appendingPathComponent("RadioCovers", isDirectory: true)
|
|
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
|
|
}
|
|
|
|
private func coverURL(for stationId: String) -> URL {
|
|
coverDir.appendingPathComponent("\(stationId).jpg")
|
|
}
|
|
|
|
func hasCover(for stationId: String) -> Bool {
|
|
fileManager.fileExists(atPath: coverURL(for: stationId).path)
|
|
}
|
|
|
|
func loadCover(for stationId: String) -> UIImage? {
|
|
let url = coverURL(for: stationId)
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
return UIImage(data: data)
|
|
}
|
|
|
|
func saveCover(_ image: UIImage, for stationId: String) {
|
|
let url = coverURL(for: stationId)
|
|
if let data = image.jpegData(compressionQuality: 0.85) {
|
|
try? data.write(to: url, options: .atomic)
|
|
DispatchQueue.main.async { self.updateTrigger = UUID() }
|
|
}
|
|
}
|
|
|
|
func removeCover(for stationId: String) {
|
|
let url = coverURL(for: stationId)
|
|
try? fileManager.removeItem(at: url)
|
|
DispatchQueue.main.async { self.updateTrigger = UUID() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Radio Station Cover View
|
|
|
|
struct RadioStationCover: View {
|
|
let stationId: String
|
|
let isPlaying: Bool
|
|
@ObservedObject var store = RadioCoverStore.shared
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
// Depend on updateTrigger so we refresh when covers change
|
|
let _ = store.updateTrigger
|
|
|
|
if let img = store.loadCover(for: stationId) {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: 56, height: 56)
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(isPlaying ? accentPink : Color.clear, lineWidth: 2)
|
|
)
|
|
} else {
|
|
// Default icon
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(isPlaying ? accentPink.opacity(0.2) : Color.white.opacity(0.05))
|
|
.frame(width: 56, height: 56)
|
|
|
|
Image(systemName: isPlaying ? "antenna.radiowaves.left.and.right" : "radio")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(isPlaying ? accentPink : .gray)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Radio View
|
|
|
|
struct RadioView: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
@EnvironmentObject var audioPlayer: AudioPlayer
|
|
@ObservedObject private var libraryCache = LibraryCache.shared
|
|
|
|
@State private var stations: [RadioStation] = []
|
|
@State private var isLoading = true
|
|
@State private var playingStationId: String?
|
|
@State private var pickerStationId: String?
|
|
@State private var selectedPhoto: PhotosPickerItem?
|
|
@State private var showPhotoPicker = false
|
|
@State private var showAddStation = false
|
|
@State private var newStationName = ""
|
|
@State private var newStationURL = ""
|
|
@State private var pendingStationPhoto: PhotosPickerItem?
|
|
|
|
@ObservedObject private var coverStore = RadioCoverStore.shared
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if isLoading && stations.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if stations.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.gray)
|
|
Text("No radio stations")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.gray)
|
|
Text("Add stations in your Navidrome settings")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray.opacity(0.7))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
// Offline banner — radio always requires a live connection
|
|
if !libraryCache.isServerAvailable {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "wifi.slash")
|
|
.font(.system(size: 13))
|
|
Text("Radio requires a server connection")
|
|
.font(.system(size: 13))
|
|
}
|
|
.foregroundColor(.white.opacity(0.8))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
.background(Color.orange.opacity(0.75))
|
|
}
|
|
List {
|
|
ForEach(stations) { station in
|
|
stationRow(station)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(Color(white: 0.06))
|
|
.navigationTitle("Radio")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: { showAddStation = true }) {
|
|
Image(systemName: "plus")
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
.alert("Add Radio Station", isPresented: $showAddStation) {
|
|
TextField("Station Name", text: $newStationName)
|
|
.keyboardDoneButton()
|
|
TextField("Stream URL", text: $newStationURL)
|
|
.keyboardDoneButton()
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
Button("Add") {
|
|
let name = newStationName
|
|
let url = newStationURL
|
|
newStationName = ""
|
|
newStationURL = ""
|
|
Task {
|
|
try? await serverManager.client.createInternetRadioStation(streamUrl: url, name: name)
|
|
await loadStations()
|
|
|
|
// If user had selected a photo, apply it after station is created
|
|
if let photo = pendingStationPhoto {
|
|
if let data = try? await photo.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
// Find the newly created station by name
|
|
let refreshed = try? await serverManager.client.getInternetRadioStations()
|
|
if let newStation = refreshed?.first(where: { $0.name == name }) {
|
|
RadioCoverStore.shared.saveCover(image, for: newStation.id)
|
|
}
|
|
}
|
|
pendingStationPhoto = nil
|
|
}
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
newStationName = ""
|
|
newStationURL = ""
|
|
pendingStationPhoto = nil
|
|
}
|
|
}
|
|
.task { await loadStations() }
|
|
.refreshable { await loadStations() }
|
|
.photosPicker(
|
|
isPresented: $showPhotoPicker,
|
|
selection: $selectedPhoto,
|
|
matching: .images,
|
|
photoLibrary: .shared()
|
|
)
|
|
.onChange(of: selectedPhoto) { _, newItem in
|
|
guard let item = newItem, let stationId = pickerStationId else { return }
|
|
Task {
|
|
if let data = try? await item.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
// Resize to reasonable size
|
|
let resized = resizeImage(image, maxSize: 300)
|
|
coverStore.saveCover(resized, for: stationId)
|
|
}
|
|
selectedPhoto = nil
|
|
pickerStationId = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Station Row
|
|
|
|
@ViewBuilder
|
|
private func stationRow(_ station: RadioStation) -> some View {
|
|
Button(action: {
|
|
if libraryCache.isServerAvailable { playStation(station) }
|
|
}) {
|
|
HStack(spacing: 14) {
|
|
RadioStationCover(
|
|
stationId: station.id,
|
|
isPlaying: playingStationId == station.id
|
|
)
|
|
.opacity(libraryCache.isServerAvailable ? 1.0 : 0.4)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(station.name)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(
|
|
!libraryCache.isServerAvailable ? .gray.opacity(0.4) :
|
|
playingStationId == station.id ? accentPink : .white
|
|
)
|
|
|
|
if let home = station.homePageUrl, !home.isEmpty {
|
|
Text(home)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(libraryCache.isServerAvailable ? .gray : .gray.opacity(0.3))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if playingStationId == station.id && libraryCache.isServerAvailable {
|
|
Image(systemName: "waveform")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
pickerStationId = station.id
|
|
showPhotoPicker = true
|
|
}) {
|
|
Label("Set Cover Image", systemImage: "photo.on.rectangle")
|
|
}
|
|
|
|
if coverStore.hasCover(for: station.id) {
|
|
Button(role: .destructive, action: {
|
|
coverStore.removeCover(for: station.id)
|
|
}) {
|
|
Label("Remove Cover", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(role: .destructive, action: {
|
|
Task {
|
|
try? await serverManager.client.deleteInternetRadioStation(id: station.id)
|
|
await loadStations()
|
|
}
|
|
}) {
|
|
Label("Delete Station", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
private func loadStations() async {
|
|
isLoading = true
|
|
let cache = LibraryCache.shared
|
|
|
|
if stations.isEmpty, let cached = cache.loadRadioStations() {
|
|
stations = cached
|
|
}
|
|
|
|
do {
|
|
let result = try await serverManager.client.getInternetRadioStations()
|
|
cache.cacheRadioStations(result)
|
|
await MainActor.run {
|
|
stations = result
|
|
isLoading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run { isLoading = false }
|
|
}
|
|
}
|
|
|
|
// MARK: - Playback
|
|
|
|
private func playStation(_ station: RadioStation) {
|
|
guard let url = URL(string: station.streamUrl) else { return }
|
|
|
|
if playingStationId == station.id {
|
|
audioPlayer.stop()
|
|
playingStationId = nil
|
|
return
|
|
}
|
|
|
|
let radioSong = Song(
|
|
id: station.id, parent: nil, isDir: nil,
|
|
title: station.name, album: "Radio",
|
|
artist: station.homePageUrl ?? "Internet Radio",
|
|
track: nil, year: nil, genre: nil, coverArt: nil,
|
|
size: nil, contentType: nil, suffix: nil,
|
|
transcodedContentType: nil, transcodedSuffix: nil,
|
|
duration: 0, bitRate: nil, path: nil, playCount: nil,
|
|
discNumber: nil, created: nil, albumId: nil, artistId: nil,
|
|
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
|
|
)
|
|
|
|
audioPlayer.playRadio(song: radioSong, streamURL: url)
|
|
playingStationId = station.id
|
|
|
|
// Set lock screen / Dynamic Island artwork if custom cover exists
|
|
if let coverImage = coverStore.loadCover(for: station.id) {
|
|
audioPlayer.setNowPlayingArtwork(image: coverImage)
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Resize
|
|
|
|
private func resizeImage(_ image: UIImage, maxSize: CGFloat) -> UIImage {
|
|
let size = image.size
|
|
let ratio = min(maxSize / size.width, maxSize / size.height)
|
|
if ratio >= 1 { return image }
|
|
|
|
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
|
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
|
return renderer.image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
}
|
|
}
|
|
}
|