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:
2026-08-18 16:15:21 +02:00
co-authored by Cursor
parent 3afcba0d36
commit a1aaefd47a
11 changed files with 728 additions and 1 deletions
@@ -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()
}
}