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
399 lines
16 KiB
Swift
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
|
|
}
|
|
}
|