Add multi-source streaming to igiRadio (web radio, URL, local files).
Supports POST /api/streaming for HTTP MP3 stations and phone PCM push for on-device audio files, with a dedicated Stream tab and preset for Radio Monte Carlo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,6 +45,11 @@ final class MockDigiRadioService: DigiRadioService {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func setStreaming(enabled: Bool, url: String) async throws {
|
||||
try await delay()
|
||||
state.streaming = StreamingState(enabled: enabled, url: url)
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.band = .fm
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Decodes local audio files to interleaved PCM s16le stereo @ 48 kHz for PUT /api/stream/phone.
|
||||
enum LocalAudioDecoder {
|
||||
static let outputSampleRate: Double = 48_000
|
||||
static let chunkFrames: AVAudioFrameCount = 2048
|
||||
|
||||
static func pcmChunks(from fileURL: URL) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
let accessed = fileURL.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
let inputFile = try AVAudioFile(forReading: fileURL)
|
||||
guard let outputFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatInt16,
|
||||
sampleRate: outputSampleRate,
|
||||
channels: 2,
|
||||
interleaved: true
|
||||
) else {
|
||||
throw PhoneStreamError.formatUnsupported
|
||||
}
|
||||
|
||||
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFormat) else {
|
||||
throw PhoneStreamError.formatUnsupported
|
||||
}
|
||||
|
||||
let inputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: inputFile.processingFormat,
|
||||
frameCapacity: chunkFrames
|
||||
)!
|
||||
let outputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: outputFormat,
|
||||
frameCapacity: chunkFrames
|
||||
)!
|
||||
|
||||
while inputFile.framePosition < inputFile.length {
|
||||
try Task.checkCancellation()
|
||||
try inputFile.read(into: inputBuffer, frameCount: chunkFrames)
|
||||
if inputBuffer.frameLength == 0 { break }
|
||||
|
||||
var inputProvided = false
|
||||
var convertError: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &convertError) { _, outStatus in
|
||||
if inputProvided {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
inputProvided = true
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
if status == .error {
|
||||
throw convertError ?? PhoneStreamError.decodeFailed
|
||||
}
|
||||
if outputBuffer.frameLength == 0 { continue }
|
||||
|
||||
let byteCount = Int(outputBuffer.frameLength) * Int(outputFormat.streamDescription.pointee.mBytesPerFrame)
|
||||
guard let channelData = outputBuffer.int16ChannelData else { continue }
|
||||
continuation.yield(Data(bytes: channelData[0], count: byteCount))
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PhoneStreamError: LocalizedError {
|
||||
case notConnected
|
||||
case formatUnsupported
|
||||
case decodeFailed
|
||||
case deviceRejected(Int, String?)
|
||||
case transport(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "DigiRadio non connesso."
|
||||
case .formatUnsupported: return "Formato audio non supportato."
|
||||
case .decodeFailed: return "Impossibile decodificare il file."
|
||||
case let .deviceRejected(code, body): return "Device HTTP \(code): \(body ?? "")"
|
||||
case let .transport(error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
/// Streams PCM to DigiRadio via documented PUT /api/stream/phone (chunked HTTP/1.1).
|
||||
actor PhonePCMStreamService {
|
||||
static let shared = PhonePCMStreamService()
|
||||
|
||||
private(set) var isStreaming = false
|
||||
private(set) var statusMessage = ""
|
||||
private var streamTask: Task<Void, Never>?
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "PhoneStream")
|
||||
|
||||
func start(fileURL: URL, connectionHost: String) {
|
||||
stop()
|
||||
let host = Self.parseHost(connectionHost)
|
||||
guard !host.isEmpty else {
|
||||
statusMessage = "Host non valido"
|
||||
return
|
||||
}
|
||||
|
||||
isStreaming = true
|
||||
statusMessage = "Avvio stream…"
|
||||
streamTask = Task {
|
||||
do {
|
||||
try await stream(fileURL: fileURL, host: host)
|
||||
statusMessage = "Stream completato"
|
||||
isStreaming = false
|
||||
} catch is CancellationError {
|
||||
statusMessage = "Stream interrotto"
|
||||
isStreaming = false
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
isStreaming = false
|
||||
logger.error("Phone stream failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
streamTask?.cancel()
|
||||
streamTask = nil
|
||||
isStreaming = false
|
||||
}
|
||||
|
||||
private func stream(fileURL: URL, host: String) async throws {
|
||||
let chunks = LocalAudioDecoder.pcmChunks(from: fileURL)
|
||||
try await sendChunkedPUT(host: host, chunks: chunks)
|
||||
}
|
||||
|
||||
private func sendChunkedPUT(host: String, chunks: AsyncThrowingStream<Data, Error>) async throws {
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: 80, using: .tcp)
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
try await waitUntilReady(connection)
|
||||
|
||||
let header = """
|
||||
PUT /api/stream/phone HTTP/1.1\r
|
||||
Host: \(host)\r
|
||||
Transfer-Encoding: chunked\r
|
||||
Connection: close\r
|
||||
Content-Type: application/octet-stream\r
|
||||
\r
|
||||
"""
|
||||
try await send(connection, Data(header.utf8))
|
||||
|
||||
for try await chunk in chunks {
|
||||
try Task.checkCancellation()
|
||||
try await sendChunk(connection, chunk)
|
||||
statusMessage = "Streaming…"
|
||||
}
|
||||
|
||||
try await send(connection, Data("0\r\n\r\n".utf8))
|
||||
_ = try await readResponse(connection)
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
private func waitUntilReady(_ connection: NWConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
var finished = false
|
||||
connection.stateUpdateHandler = { state in
|
||||
guard !finished else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
finished = true
|
||||
continuation.resume()
|
||||
case let .failed(error):
|
||||
finished = true
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ connection: NWConnection, _ data: Data) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func sendChunk(_ connection: NWConnection, _ chunk: Data) async throws {
|
||||
var payload = Data()
|
||||
payload.append(contentsOf: "\(String(chunk.count, radix: 16, uppercase: false))\r\n".utf8)
|
||||
payload.append(chunk)
|
||||
payload.append(contentsOf: "\r\n".utf8)
|
||||
try await send(connection, payload)
|
||||
}
|
||||
|
||||
private func readResponse(_ connection: NWConnection) async throws -> Int {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
return
|
||||
}
|
||||
guard let data, let text = String(data: data, encoding: .utf8) else {
|
||||
continuation.resume(returning: 200)
|
||||
return
|
||||
}
|
||||
let statusLine = text.split(separator: "\r\n").first.map(String.init) ?? ""
|
||||
if statusLine.contains("409") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(409, "Stream occupato (web radio attiva?)"))
|
||||
} else if statusLine.contains("503") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(503, "Sink I2S non disponibile"))
|
||||
} else if statusLine.contains("200") {
|
||||
continuation.resume(returning: 200)
|
||||
} else if let code = Int(statusLine.split(separator: " ").dropFirst().first ?? "") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(code, statusLine))
|
||||
} else {
|
||||
continuation.resume(returning: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseHost(_ raw: String) -> String {
|
||||
var h = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
h = h.replacingOccurrences(of: "http://", with: "")
|
||||
h = h.replacingOccurrences(of: "https://", with: "")
|
||||
if let slash = h.firstIndex(of: "/") {
|
||||
h = String(h[..<slash])
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ protocol DigiRadioService: AnyObject {
|
||||
func refreshBluetoothStatus() async throws
|
||||
func refreshStations() async throws
|
||||
func refreshStreaming() async throws
|
||||
func setStreaming(enabled: Bool, url: String) async throws
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws
|
||||
func tuneDAB(freqIndex: Int) async throws
|
||||
|
||||
@@ -77,6 +77,11 @@ final class RealDigiRadioService: DigiRadioService {
|
||||
state.streaming = try await client.fetchStreaming()
|
||||
}
|
||||
|
||||
func setStreaming(enabled: Bool, url: String) async throws {
|
||||
let config = StreamingState(enabled: enabled, url: url)
|
||||
state.streaming = try await client.setStreaming(config)
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
|
||||
applyTuner(tuner)
|
||||
|
||||
@@ -138,6 +138,10 @@ final class HTTPDigiRadioClient {
|
||||
try await get("/api/streaming")
|
||||
}
|
||||
|
||||
func setStreaming(_ config: StreamingState) async throws -> StreamingState {
|
||||
try await post("/api/streaming", body: config)
|
||||
}
|
||||
|
||||
// MARK: - HTTP helpers
|
||||
|
||||
private func url(for path: String) throws -> URL {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum StreamSource: String, CaseIterable, Identifiable {
|
||||
case webRadio = "Radio web"
|
||||
case urlContent = "URL contenuto"
|
||||
case localFile = "File iPhone"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .webRadio: "MP3 HTTP sul DigiRadio (decoder ESP32)"
|
||||
case .urlContent: "Podcast, playlist o stream personalizzato"
|
||||
case .localFile: "File audio decodificato e inviato dal telefono"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .webRadio: "antenna.radiowaves.left.and.right"
|
||||
case .urlContent: "link"
|
||||
case .localFile: "music.note.list"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamPreset: Identifiable {
|
||||
var id: String { name }
|
||||
var name: String
|
||||
var url: String
|
||||
}
|
||||
|
||||
enum StreamPresets {
|
||||
/// Default from firmware WebRadioConfig.hpp (documented example).
|
||||
static let catalog: [StreamPreset] = [
|
||||
StreamPreset(name: "Radio Monte Carlo", url: "http://edge.radiomontecarlo.net/RMC.mp3"),
|
||||
StreamPreset(name: "Esempio MP3", url: "http://stream.example.com/radio.mp3")
|
||||
]
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class StreamingViewModel {
|
||||
private let service: any DigiRadioService
|
||||
|
||||
var source: StreamSource = .webRadio
|
||||
var enabled = false
|
||||
var url = StreamPresets.catalog[0].url
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var errorMessage: String?
|
||||
|
||||
var selectedFileName = ""
|
||||
var selectedFileURL: URL?
|
||||
var phoneStreamActive = false
|
||||
var phoneStreamStatus = ""
|
||||
|
||||
private var statusPollTask: Task<Void, Never>?
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
deinit {
|
||||
statusPollTask?.cancel()
|
||||
}
|
||||
|
||||
var connectionHost: String {
|
||||
service.state.connection.host
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
service.state.connection.isConnected
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await service.refreshStreaming()
|
||||
syncFromDevice()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
startStatusPolling()
|
||||
}
|
||||
|
||||
func selectPreset(_ preset: StreamPreset) {
|
||||
url = preset.url
|
||||
source = .webRadio
|
||||
}
|
||||
|
||||
func normalizedHTTPURL() -> String? {
|
||||
var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if !trimmed.hasPrefix("http://") {
|
||||
if trimmed.hasPrefix("https://") {
|
||||
errorMessage = "Il firmware accetta solo URL http:// (non https)."
|
||||
return nil
|
||||
}
|
||||
trimmed = "http://\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func playWebStream() async {
|
||||
guard let normalized = normalizedHTTPURL() else { return }
|
||||
url = normalized
|
||||
await apply(enabled: true)
|
||||
}
|
||||
|
||||
func stopWebStream() async {
|
||||
await apply(enabled: false)
|
||||
}
|
||||
|
||||
func apply(enabled: Bool? = nil) async {
|
||||
guard let normalized = normalizedHTTPURL() else { return }
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let targetEnabled = enabled ?? self.enabled
|
||||
do {
|
||||
try await service.setStreaming(enabled: targetEnabled, url: normalized)
|
||||
syncFromDevice()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func pickFile(_ url: URL) {
|
||||
selectedFileURL = url
|
||||
selectedFileName = url.lastPathComponent
|
||||
source = .localFile
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func playLocalFile() async {
|
||||
guard isConnected, let fileURL = selectedFileURL else {
|
||||
errorMessage = "Seleziona un file e connetti DigiRadio."
|
||||
return
|
||||
}
|
||||
errorMessage = nil
|
||||
await stopWebStreamSilently()
|
||||
await PhonePCMStreamService.shared.start(fileURL: fileURL, connectionHost: connectionHost)
|
||||
}
|
||||
|
||||
func stopLocalFile() async {
|
||||
await PhonePCMStreamService.shared.stop()
|
||||
phoneStreamActive = false
|
||||
phoneStreamStatus = "Fermo"
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
statusPollTask?.cancel()
|
||||
statusPollTask = nil
|
||||
}
|
||||
|
||||
private func stopWebStreamSilently() async {
|
||||
try? await service.setStreaming(enabled: false, url: url)
|
||||
syncFromDevice()
|
||||
}
|
||||
|
||||
private func syncFromDevice() {
|
||||
enabled = service.state.streaming.enabled
|
||||
if !service.state.streaming.url.isEmpty {
|
||||
url = service.state.streaming.url
|
||||
}
|
||||
}
|
||||
|
||||
private func startStatusPolling() {
|
||||
statusPollTask?.cancel()
|
||||
statusPollTask = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
let streamer = PhonePCMStreamService.shared
|
||||
phoneStreamActive = await streamer.isStreaming
|
||||
let status = await streamer.statusMessage
|
||||
if !status.isEmpty { phoneStreamStatus = status }
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ struct HomeView: View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
header(state: state)
|
||||
nowPlayingCard(state: state)
|
||||
streamCard(state: state)
|
||||
quickLinks
|
||||
IGITransportCluster(
|
||||
onPrevious: { Task { await viewModel?.seekFM("down") } },
|
||||
@@ -45,6 +46,7 @@ struct HomeView: View {
|
||||
}
|
||||
viewModel?.onAppear()
|
||||
volume = Double(state.tuner.volume)
|
||||
Task { try? await environment.digiRadio.refreshStreaming() }
|
||||
}
|
||||
.onDisappear { viewModel?.onDisappear() }
|
||||
.onChange(of: environment.state.tuner.volume) { _, newValue in
|
||||
@@ -144,6 +146,43 @@ struct HomeView: View {
|
||||
.animation(.snappy, value: state.tuner.fm?.stationName)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func streamCard(state: DigiRadioState) -> some View {
|
||||
NavigationLink {
|
||||
StreamingView()
|
||||
} label: {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(state.streaming.enabled ? IGITheme.accent.opacity(0.2) : Color.secondary.opacity(0.12))
|
||||
.frame(width: 52, height: 52)
|
||||
Image(systemName: state.streaming.enabled ? "dot.radiowaves.forward" : "dot.radiowaves.forward.slash")
|
||||
.font(.title2)
|
||||
.foregroundStyle(state.streaming.enabled ? IGITheme.accent : .secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Web radio stream")
|
||||
.font(.headline)
|
||||
if state.streaming.enabled, !state.streaming.url.isEmpty {
|
||||
Text(state.streaming.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Nessuno stream attivo")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var quickLinks: some View {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
NavigationLink {
|
||||
@@ -161,6 +200,11 @@ struct HomeView: View {
|
||||
} label: {
|
||||
quickLinkLabel("Audio", icon: "slider.vertical.3")
|
||||
}
|
||||
NavigationLink {
|
||||
StreamingView()
|
||||
} label: {
|
||||
quickLinkLabel("Stream", icon: "dot.radiowaves.forward")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ private struct IPhoneRootView: View {
|
||||
NavigationStack { DABRadioView() }
|
||||
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
|
||||
|
||||
NavigationStack { StreamingView() }
|
||||
.tabItem { Label("Stream", systemImage: "dot.radiowaves.forward") }
|
||||
|
||||
NavigationStack { PresetsView() }
|
||||
.tabItem { Label("Preset", systemImage: "star.fill") }
|
||||
|
||||
@@ -58,6 +61,7 @@ private struct IPadRootView: View {
|
||||
case .home: HomeView()
|
||||
case .fm: FMRadioView()
|
||||
case .dab: DABRadioView()
|
||||
case .stream: StreamingView()
|
||||
case .bluetooth: BluetoothView()
|
||||
case .audio: AudioView()
|
||||
case .presets: PresetsView()
|
||||
@@ -68,7 +72,7 @@ private struct IPadRootView: View {
|
||||
}
|
||||
|
||||
enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case home, fm, dab, bluetooth, audio, presets, settings
|
||||
case home, fm, dab, stream, bluetooth, audio, presets, settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -77,6 +81,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case .home: "Home"
|
||||
case .fm: "FM"
|
||||
case .dab: "DAB"
|
||||
case .stream: "Stream"
|
||||
case .bluetooth: "Bluetooth"
|
||||
case .audio: "Audio"
|
||||
case .presets: "Preset"
|
||||
@@ -89,6 +94,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case .home: "house.fill"
|
||||
case .fm: "dot.radiowaves.left.and.right"
|
||||
case .dab: "antenna.radiowaves.left.and.right"
|
||||
case .stream: "dot.radiowaves.forward"
|
||||
case .bluetooth: "dot.radiowaves.left.and.right"
|
||||
case .audio: "waveform"
|
||||
case .presets: "star.fill"
|
||||
|
||||
@@ -12,6 +12,7 @@ struct SettingsRootView: View {
|
||||
Section("Radio") {
|
||||
NavigationLink("FM") { FMRadioView() }
|
||||
NavigationLink("DAB") { DABRadioView() }
|
||||
NavigationLink("Web radio stream") { StreamingView() }
|
||||
NavigationLink("Preset") { PresetsView() }
|
||||
}
|
||||
Section("Audio") {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct StreamingView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: StreamingViewModel?
|
||||
@State private var showFilePicker = false
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? StreamingViewModel(service: environment.digiRadio)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
sourcePicker(vm)
|
||||
statusHero(vm)
|
||||
|
||||
switch vm.source {
|
||||
case .webRadio:
|
||||
webRadioSection(vm)
|
||||
case .urlContent:
|
||||
urlContentSection(vm)
|
||||
case .localFile:
|
||||
localFileSection(vm)
|
||||
}
|
||||
|
||||
if let error = vm.errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.igiPremiumCard()
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle("Stream")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.fileImporter(
|
||||
isPresented: $showFilePicker,
|
||||
allowedContentTypes: [.audio, .mp3, .mpeg4Audio, .wav, .aiff],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(urls) = result, let url = urls.first {
|
||||
vm.pickFile(url)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
Task { await vm.load() }
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel?.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourcePicker(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
Text("Sorgente")
|
||||
.font(.headline)
|
||||
ForEach(StreamSource.allCases) { item in
|
||||
Button {
|
||||
withAnimation(.snappy) { vm.source = item }
|
||||
} label: {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: item.icon)
|
||||
.frame(width: 28)
|
||||
.foregroundStyle(vm.source == item ? .white : IGITheme.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.rawValue).font(.subheadline.weight(.semibold))
|
||||
Text(item.subtitle).font(.caption2).foregroundStyle(vm.source == item ? .white.opacity(0.85) : .secondary)
|
||||
}
|
||||
Spacer()
|
||||
if vm.source == item {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
.background(
|
||||
vm.source == item
|
||||
? AnyShapeStyle(IGITheme.accent.gradient)
|
||||
: AnyShapeStyle(Color.primary.opacity(0.05)),
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(vm.source == item ? .white : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusHero(_ vm: StreamingViewModel) -> some View {
|
||||
let active = vm.source == .localFile ? vm.phoneStreamActive : vm.enabled
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: active ? "dot.radiowaves.forward" : "pause.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(active ? IGITheme.accent : .secondary)
|
||||
.symbolEffect(.pulse, options: .repeating, value: active)
|
||||
|
||||
Text(active ? "In riproduzione" : "Fermo")
|
||||
.font(.title3.weight(.bold))
|
||||
Text(statusSubtitle(vm))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
private func statusSubtitle(_ vm: StreamingViewModel) -> String {
|
||||
switch vm.source {
|
||||
case .webRadio, .urlContent:
|
||||
return vm.enabled ? vm.url : "Nessuno stream web attivo"
|
||||
case .localFile:
|
||||
return vm.phoneStreamStatus.isEmpty ? "Nessun file in invio" : vm.phoneStreamStatus
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func webRadioSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("Stazioni")
|
||||
.font(.headline)
|
||||
ForEach(StreamPresets.catalog) { preset in
|
||||
Button {
|
||||
vm.selectPreset(preset)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(preset.name).font(.headline)
|
||||
Text(preset.url).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "play.circle.fill").font(.title2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if preset.id != StreamPresets.catalog.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
|
||||
streamURLControls(vm, playLabel: "Play radio")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func urlContentSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
Text("Contenuto via URL")
|
||||
.font(.headline)
|
||||
Text("Il DigiRadio scarica e decodifica MP3 HTTP sul device. L'URL deve iniziare con http:// (max 200 caratteri).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.igiPremiumCard()
|
||||
|
||||
streamURLControls(vm, playLabel: "Play contenuto")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func streamURLControls(_ vm: StreamingViewModel, playLabel: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
TextField("http://…", text: Binding(get: { vm.url }, set: { vm.url = $0 }))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.padding()
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Button { Task { await vm.playWebStream() } } label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isSaving || !vm.isConnected)
|
||||
|
||||
Button { Task { await vm.stopWebStream() } } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(vm.isSaving)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func localFileSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("File sul telefono")
|
||||
.font(.headline)
|
||||
Text("L'iPhone decodifica il file e invia PCM stereo 48 kHz a PUT /api/stream/phone. Ferma prima eventuali stream web sul device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
showFilePicker = true
|
||||
} label: {
|
||||
Label(vm.selectedFileName.isEmpty ? "Scegli file audio" : vm.selectedFileName, systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Button { Task { await vm.playLocalFile() } } label: {
|
||||
Label("Invia a DigiRadio", systemImage: "arrow.up.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!vm.isConnected || vm.selectedFileURL == nil || vm.phoneStreamActive)
|
||||
|
||||
Button { Task { await vm.stopLocalFile() } } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!vm.phoneStreamActive)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user