From a1aaefd47a0097fc79b2ad531c7c33b4b2f592df Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 18 Aug 2026 16:15:21 +0200 Subject: [PATCH] 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 --- .../igiRadio/Mock/MockDigiRadioService.swift | 5 + .../Services/Audio/LocalAudioDecoder.swift | 90 +++++++ .../Audio/PhonePCMStreamService.swift | 152 +++++++++++ .../Services/Device/DigiRadioService.swift | 1 + .../Device/RealDigiRadioService.swift | 5 + .../HTTPDigiRadioClient.swift | 4 + .../ViewModels/StreamingViewModel.swift | 184 ++++++++++++++ .../igiRadio/igiRadio/Views/HomeView.swift | 44 ++++ .../igiRadio/igiRadio/Views/RootView.swift | 8 +- .../igiRadio/Views/SettingsViews.swift | 1 + .../igiRadio/Views/StreamingView.swift | 235 ++++++++++++++++++ 11 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift create mode 100644 Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift create mode 100644 Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift create mode 100644 Software/APP/igiRadio/igiRadio/Views/StreamingView.swift diff --git a/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift index e3ae4be..c172e3a 100644 --- a/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift +++ b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift @@ -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 diff --git a/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift b/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift new file mode 100644 index 0000000..0da750c --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift @@ -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 { + 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 + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift b/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift new file mode 100644 index 0000000..3e83bcb --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift @@ -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? + 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) 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) 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) 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[.. StreamingState { + try await post("/api/streaming", body: config) + } + // MARK: - HTTP helpers private func url(for path: String) throws -> URL { diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift new file mode 100644 index 0000000..65d3b2a --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift @@ -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? + + 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)) + } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift index 8e6b71c..ff11ec1 100644 --- a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift @@ -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") + } } } diff --git a/Software/APP/igiRadio/igiRadio/Views/RootView.swift b/Software/APP/igiRadio/igiRadio/Views/RootView.swift index 787a8d8..f1d3a08 100644 --- a/Software/APP/igiRadio/igiRadio/Views/RootView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/RootView.swift @@ -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" diff --git a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift index 00de520..69b853c 100644 --- a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift +++ b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift @@ -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") { diff --git a/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift b/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift new file mode 100644 index 0000000..3721b59 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift @@ -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() + } +}