import Foundation import AVFoundation import Combine /// Captures a radio stream into a local file buffer for timeshift scrubbing and recording. /// No microphone is used — the raw stream bytes are saved directly from URLSession. class RadioStreamBuffer: NSObject, ObservableObject, URLSessionDataDelegate { static let shared = RadioStreamBuffer() // MARK: - Published State @Published var isBuffering = false @Published var isRecording = false @Published var recordingTimeFormatted: String = "0:00" @Published var bufferedDuration: TimeInterval = 0 @Published var isLive = true @Published var isHLSStream = false // HLS streams can't be raw-buffered // MARK: - Config let maxBufferDuration: TimeInterval = 20 * 60 // 20 minutes // MARK: - Internal private var dataTask: URLSessionDataTask? private var urlSession: URLSession? private var bufferFileHandle: FileHandle? private var bufferFileURL: URL? private var totalBytesWritten: Int64 = 0 private var streamStartTime: Date? private var estimatedBitRate: Double = 128000 // bits per second, updated from headers private var recordingStartByte: Int64 = 0 private var recordingTimer: Timer? private var recordingStartTime: Date? private var stationName: String = "" private var bufferGeneration: Int = 0 // Prevents old URLSession callbacks from overwriting state private override init() { super.init() } // MARK: - Start Buffering private func rlog(_ msg: String) { DebugLogger.shared.log(msg, category: "Radio") } /// Start capturing a radio stream. Call this when a radio station starts playing. /// The URL should already be resolved (not a .pls/.m3u playlist) — use resolveStreamURL() first. func startBuffering(url: URL, stationName: String) { // Increment generation BEFORE stopping — so old callbacks are ignored bufferGeneration += 1 let gen = bufferGeneration stopBuffering() self.stationName = stationName isHLSStream = false rlog("Start buffering (gen \(gen)): \(stationName) → \(url.absoluteString)") // Detect HLS from URL extension let ext = url.pathExtension.lowercased() if ext == "m3u8" { rlog("HLS stream detected from URL extension — buffering/recording unavailable") isHLSStream = true isBuffering = false return } // Create temp file let tempDir = FileManager.default.temporaryDirectory let filename = "radio_buffer_\(UUID().uuidString).raw" let fileURL = tempDir.appendingPathComponent(filename) FileManager.default.createFile(atPath: fileURL.path, contents: nil) do { bufferFileHandle = try FileHandle(forWritingTo: fileURL) } catch { rlog("Failed to create buffer file: \(error)") return } bufferFileURL = fileURL totalBytesWritten = 0 streamStartTime = Date() isBuffering = true isLive = true bufferedDuration = 0 let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.waitsForConnectivity = true urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) dataTask = urlSession?.dataTask(with: url) dataTask?.resume() } /// Stop capturing — cleans up temp file func stopBuffering() { if isBuffering { rlog("Stop buffering: \(stationName)") } _ = stopRecording() dataTask?.cancel() dataTask = nil urlSession?.invalidateAndCancel() urlSession = nil bufferFileHandle?.closeFile() bufferFileHandle = nil // Clean up temp file if let url = bufferFileURL { try? FileManager.default.removeItem(at: url) } bufferFileURL = nil totalBytesWritten = 0 streamStartTime = nil isBuffering = false isHLSStream = false bufferedDuration = 0 isLive = true } // MARK: - URLSessionDataDelegate func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { if let httpResponse = response as? HTTPURLResponse { rlog("Stream response: HTTP \(httpResponse.statusCode)") let ct = httpResponse.mimeType?.lowercased() ?? "" // Detect HLS from content-type let hlsTypes = ["application/vnd.apple.mpegurl", "application/x-mpegurl"] if hlsTypes.contains(ct) || (ct.contains("mpegurl") && ct.contains("apple")) { rlog("HLS stream detected from content-type (\(ct)) — cancelling raw buffer") DispatchQueue.main.async { self.isHLSStream = true self.isBuffering = false } completionHandler(.cancel) return } // Detect playlist content-types (PLS, M3U, ASX) — shouldn't happen // if resolution worked, but guard against it if Self.playlistContentTypes.contains(ct) { rlog("Playlist content-type detected (\(ct)) — URL was not resolved. Cancelling buffer.") DispatchQueue.main.async { self.isBuffering = false } completionHandler(.cancel) return } if let icyBR = httpResponse.allHeaderFields["icy-br"] as? String, let br = Double(icyBR) { estimatedBitRate = br * 1000 rlog("Stream bitrate (icy-br): \(icyBR) kbps") } if let mimeType = httpResponse.mimeType { rlog("Stream content-type: \(mimeType)") } } completionHandler(.allow) } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { bufferFileHandle?.write(data) totalBytesWritten += Int64(data.count) let bytesPerSecond = estimatedBitRate / 8.0 let estDuration = Double(totalBytesWritten) / bytesPerSecond DispatchQueue.main.async { self.bufferedDuration = estDuration } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { let gen = bufferGeneration // Capture current generation if let error = error as? NSError, error.code != NSURLErrorCancelled { rlog("Stream ended with error (gen \(gen)): \(error.localizedDescription)") } else if error == nil { rlog("Stream ended normally (gen \(gen))") } else { rlog("Stream cancelled (gen \(gen))") } DispatchQueue.main.async { [weak self] in guard let self = self else { return } // Only mark as not buffering if this callback is from the CURRENT session // Old cancelled sessions must not reset the flag if gen == self.bufferGeneration { self.isBuffering = false self.rlog("isBuffering → false (gen \(gen) matches)") } else { self.rlog("Ignoring stale completion (gen \(gen), current \(self.bufferGeneration))") } } } // MARK: - Timeshift Playback /// Get a playable URL for the buffer. AVPlayer can seek within this file. var bufferPlaybackURL: URL? { return bufferFileURL } /// Estimated duration of buffered content var estimatedBufferSeconds: TimeInterval { let bytesPerSecond = estimatedBitRate / 8.0 guard bytesPerSecond > 0 else { return 0 } return Double(totalBytesWritten) / bytesPerSecond } /// Convert a "seconds ago" offset to a byte position for seeking func seekPosition(secondsFromLive: TimeInterval) -> TimeInterval { let total = estimatedBufferSeconds return max(0, total - secondsFromLive) } // MARK: - Recording (no mic — direct stream capture) /// Start recording — marks the current buffer position func startRecording() { rlog("startRecording: isBuffering=\(isBuffering), isHLS=\(isHLSStream), totalBytes=\(totalBytesWritten), gen=\(bufferGeneration)") if isHLSStream { rlog("startRecording SKIPPED: HLS streams cannot be raw-captured. Recording unavailable for this station.") return } guard isBuffering else { rlog("startRecording FAILED: not buffering. URLSession state: \(urlSession != nil ? "exists" : "nil"), dataTask: \(dataTask?.state.rawValue ?? -1)") return } rlog("Recording started at byte offset \(totalBytesWritten)") recordingStartByte = totalBytesWritten recordingStartTime = Date() isRecording = true recordingTimeFormatted = "0:00" recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let start = self?.recordingStartTime else { return } let elapsed = Int(Date().timeIntervalSince(start)) let min = elapsed / 60 let sec = elapsed % 60 DispatchQueue.main.async { self?.recordingTimeFormatted = String(format: "%d:%02d", min, sec) } } } /// Stop recording — saves the captured segment to Documents/RadioRecordings/ func stopRecording() -> URL? { guard isRecording else { return nil } isRecording = false recordingTimer?.invalidate() recordingTimer = nil guard let bufferURL = bufferFileURL else { return nil } // Read the recorded segment from the buffer file let endByte = totalBytesWritten let recordedBytes = endByte - recordingStartByte guard recordedBytes > 0 else { return nil } do { let fm = FileManager.default let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first! let dir = docs.appendingPathComponent("RadioRecordings", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) let dateStr = ISO8601DateFormatter().string(from: Date()) .replacingOccurrences(of: ":", with: "-") let safeName = stationName.replacingOccurrences(of: "/", with: "-") // Determine extension from stream content let ext = guessExtension() let filename = "\(safeName)_\(dateStr).\(ext)" let destURL = dir.appendingPathComponent(filename) // Read segment from buffer file let readHandle = try FileHandle(forReadingFrom: bufferURL) readHandle.seek(toFileOffset: UInt64(recordingStartByte)) let data = readHandle.readData(ofLength: Int(recordedBytes)) readHandle.closeFile() try data.write(to: destURL) print("Saved recording: \(filename) (\(data.count) bytes)") recordingTimeFormatted = "0:00" return destURL } catch { print("Failed to save recording: \(error)") return nil } } private func guessExtension() -> String { // Default to mp3 for radio streams — most common return "mp3" } // MARK: - Playlist URL Resolver /// Playlist file extensions that wrap the actual audio stream URL private static let playlistExtensions: Set = ["pls", "m3u", "asx", "xspf"] /// Content-types that indicate a playlist, not audio private static let playlistContentTypes: Set = [ "audio/x-scpls", "audio/scpls", // PLS "audio/x-mpegurl", "audio/mpegurl", // M3U "application/vnd.apple.mpegurl", "application/x-mpegurl", // HLS M3U8 "video/x-ms-asf", "application/x-mms-framed", // ASX "application/xspf+xml" // XSPF ] /// Check if a URL points to a playlist file rather than a direct audio stream static func isPlaylistURL(_ url: URL) -> Bool { playlistExtensions.contains(url.pathExtension.lowercased()) } /// Resolve a playlist URL to the actual audio stream URL. /// If the URL is already a direct stream, returns it unchanged. static func resolveStreamURL(_ url: URL) async -> URL { let ext = url.pathExtension.lowercased() guard playlistExtensions.contains(ext) else { return url } DebugLogger.shared.log("Resolving playlist URL (\(ext)): \(url.lastPathComponent)", category: "Radio") do { let (data, response) = try await URLSession.shared.data(from: url) guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else { DebugLogger.shared.log("Playlist: couldn't decode text", category: "Radio") return url } // Check content-type to determine format let ct = (response as? HTTPURLResponse)?.mimeType?.lowercased() ?? "" // HLS — return original URL, AVPlayer handles it natively if ct.contains("mpegurl") && ext == "m3u8" { DebugLogger.shared.log("Playlist is HLS — returning original URL for AVPlayer", category: "Radio") return url } // Try parsing based on extension / content if let resolved = parsePLS(text) ?? parseM3U(text) ?? parseASX(text) { DebugLogger.shared.log("Resolved stream: \(resolved.absoluteString)", category: "Radio") return resolved } DebugLogger.shared.log("Playlist: no stream URL found in \(data.count) bytes", category: "Radio") return url } catch { DebugLogger.shared.log("Playlist resolve failed: \(error.localizedDescription)", category: "Radio") return url } } /// Parse PLS format: `File1=http://...` private static func parsePLS(_ text: String) -> URL? { for line in text.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) // Match File1=, File2=, etc. if trimmed.lowercased().hasPrefix("file"), let eqIndex = trimmed.firstIndex(of: "=") { let urlString = String(trimmed[trimmed.index(after: eqIndex)...]).trimmingCharacters(in: .whitespaces) if urlString.lowercased().hasPrefix("http"), let url = URL(string: urlString) { return url } } } return nil } /// Parse M3U format: first non-comment line starting with http private static func parseM3U(_ text: String) -> URL? { for line in text.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } if trimmed.lowercased().hasPrefix("http"), let url = URL(string: trimmed) { return url } } return nil } /// Parse ASX format: `` private static func parseASX(_ text: String) -> URL? { // Simple regex-free parse for let lower = text.lowercased() guard let refRange = lower.range(of: "href") else { return nil } let after = text[refRange.upperBound...] // Find the quoted URL guard let quoteStart = after.firstIndex(of: "\"") else { return nil } let urlStart = after.index(after: quoteStart) guard let quoteEnd = after[urlStart...].firstIndex(of: "\"") else { return nil } let urlString = String(after[urlStart..