357 lines
14 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|