NavidromeApp/iOS/Views/Library/RadioView.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

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))
}
}
}