NavidromeApp/iOS/Views/Library/RadioView.swift
2026-04-04 18:06:08 -07:00

357 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)
TextField("Stream URL", text: $newStationURL)
.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))
}
}
}