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 @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 { 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 private func stationRow(_ station: RadioStation) -> some View { Button(action: { playStation(station) }) { HStack(spacing: 14) { RadioStationCover( stationId: station.id, isPlaying: playingStationId == station.id ) VStack(alignment: .leading, spacing: 3) { Text(station.name) .font(.system(size: 16, weight: .medium)) .foregroundColor( playingStationId == station.id ? accentPink : .white ) if let home = station.homePageUrl, !home.isEmpty { Text(home) .font(.system(size: 12)) .foregroundColor(.gray) .lineLimit(1) } } Spacer() if playingStationId == station.id { 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)) } } }