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()
|
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 {
|
func tuneFM(frequencyKhz: Int) async throws {
|
||||||
try await delay()
|
try await delay()
|
||||||
state.tuner.band = .fm
|
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 refreshBluetoothStatus() async throws
|
||||||
func refreshStations() async throws
|
func refreshStations() async throws
|
||||||
func refreshStreaming() async throws
|
func refreshStreaming() async throws
|
||||||
|
func setStreaming(enabled: Bool, url: String) async throws
|
||||||
|
|
||||||
func tuneFM(frequencyKhz: Int) async throws
|
func tuneFM(frequencyKhz: Int) async throws
|
||||||
func tuneDAB(freqIndex: Int) async throws
|
func tuneDAB(freqIndex: Int) async throws
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ final class RealDigiRadioService: DigiRadioService {
|
|||||||
state.streaming = try await client.fetchStreaming()
|
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 {
|
func tuneFM(frequencyKhz: Int) async throws {
|
||||||
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
|
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
|
||||||
applyTuner(tuner)
|
applyTuner(tuner)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ final class HTTPDigiRadioClient {
|
|||||||
try await get("/api/streaming")
|
try await get("/api/streaming")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setStreaming(_ config: StreamingState) async throws -> StreamingState {
|
||||||
|
try await post("/api/streaming", body: config)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - HTTP helpers
|
// MARK: - HTTP helpers
|
||||||
|
|
||||||
private func url(for path: String) throws -> URL {
|
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) {
|
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||||
header(state: state)
|
header(state: state)
|
||||||
nowPlayingCard(state: state)
|
nowPlayingCard(state: state)
|
||||||
|
streamCard(state: state)
|
||||||
quickLinks
|
quickLinks
|
||||||
IGITransportCluster(
|
IGITransportCluster(
|
||||||
onPrevious: { Task { await viewModel?.seekFM("down") } },
|
onPrevious: { Task { await viewModel?.seekFM("down") } },
|
||||||
@@ -45,6 +46,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
viewModel?.onAppear()
|
viewModel?.onAppear()
|
||||||
volume = Double(state.tuner.volume)
|
volume = Double(state.tuner.volume)
|
||||||
|
Task { try? await environment.digiRadio.refreshStreaming() }
|
||||||
}
|
}
|
||||||
.onDisappear { viewModel?.onDisappear() }
|
.onDisappear { viewModel?.onDisappear() }
|
||||||
.onChange(of: environment.state.tuner.volume) { _, newValue in
|
.onChange(of: environment.state.tuner.volume) { _, newValue in
|
||||||
@@ -144,6 +146,43 @@ struct HomeView: View {
|
|||||||
.animation(.snappy, value: state.tuner.fm?.stationName)
|
.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 {
|
private var quickLinks: some View {
|
||||||
HStack(spacing: IGITheme.spacingS) {
|
HStack(spacing: IGITheme.spacingS) {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -161,6 +200,11 @@ struct HomeView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
quickLinkLabel("Audio", icon: "slider.vertical.3")
|
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() }
|
NavigationStack { DABRadioView() }
|
||||||
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
|
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
|
||||||
|
|
||||||
|
NavigationStack { StreamingView() }
|
||||||
|
.tabItem { Label("Stream", systemImage: "dot.radiowaves.forward") }
|
||||||
|
|
||||||
NavigationStack { PresetsView() }
|
NavigationStack { PresetsView() }
|
||||||
.tabItem { Label("Preset", systemImage: "star.fill") }
|
.tabItem { Label("Preset", systemImage: "star.fill") }
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ private struct IPadRootView: View {
|
|||||||
case .home: HomeView()
|
case .home: HomeView()
|
||||||
case .fm: FMRadioView()
|
case .fm: FMRadioView()
|
||||||
case .dab: DABRadioView()
|
case .dab: DABRadioView()
|
||||||
|
case .stream: StreamingView()
|
||||||
case .bluetooth: BluetoothView()
|
case .bluetooth: BluetoothView()
|
||||||
case .audio: AudioView()
|
case .audio: AudioView()
|
||||||
case .presets: PresetsView()
|
case .presets: PresetsView()
|
||||||
@@ -68,7 +72,7 @@ private struct IPadRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SidebarItem: String, CaseIterable, Identifiable {
|
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 }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
|||||||
case .home: "Home"
|
case .home: "Home"
|
||||||
case .fm: "FM"
|
case .fm: "FM"
|
||||||
case .dab: "DAB"
|
case .dab: "DAB"
|
||||||
|
case .stream: "Stream"
|
||||||
case .bluetooth: "Bluetooth"
|
case .bluetooth: "Bluetooth"
|
||||||
case .audio: "Audio"
|
case .audio: "Audio"
|
||||||
case .presets: "Preset"
|
case .presets: "Preset"
|
||||||
@@ -89,6 +94,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
|||||||
case .home: "house.fill"
|
case .home: "house.fill"
|
||||||
case .fm: "dot.radiowaves.left.and.right"
|
case .fm: "dot.radiowaves.left.and.right"
|
||||||
case .dab: "antenna.radiowaves.left.and.right"
|
case .dab: "antenna.radiowaves.left.and.right"
|
||||||
|
case .stream: "dot.radiowaves.forward"
|
||||||
case .bluetooth: "dot.radiowaves.left.and.right"
|
case .bluetooth: "dot.radiowaves.left.and.right"
|
||||||
case .audio: "waveform"
|
case .audio: "waveform"
|
||||||
case .presets: "star.fill"
|
case .presets: "star.fill"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SettingsRootView: View {
|
|||||||
Section("Radio") {
|
Section("Radio") {
|
||||||
NavigationLink("FM") { FMRadioView() }
|
NavigationLink("FM") { FMRadioView() }
|
||||||
NavigationLink("DAB") { DABRadioView() }
|
NavigationLink("DAB") { DABRadioView() }
|
||||||
|
NavigationLink("Web radio stream") { StreamingView() }
|
||||||
NavigationLink("Preset") { PresetsView() }
|
NavigationLink("Preset") { PresetsView() }
|
||||||
}
|
}
|
||||||
Section("Audio") {
|
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