NavidromeApp/iOS/Views/NowPlaying/RadioStreamBuffer.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

399 lines
16 KiB
Swift

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<String> = ["pls", "m3u", "asx", "xspf"]
/// Content-types that indicate a playlist, not audio
private static let playlistContentTypes: Set<String> = [
"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: `<ref href="http://..."/>`
private static func parseASX(_ text: String) -> URL? {
// Simple regex-free parse for <ref href="..."/>
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..<quoteEnd])
if urlString.lowercased().hasPrefix("http") {
return URL(string: urlString)
}
return nil
}
}