From df70a99279b05c8e260c19af168e56207ac88705 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 4 Apr 2026 19:00:31 -0700 Subject: [PATCH] Update from NavidromePlayer.zip (2026-04-04 19:00) --- Shared/Audio/AudioPlayer.swift | 134 ++++++++++++------- iOS/Views/NowPlaying/NowPlayingView.swift | 74 ++++++---- iOS/Views/NowPlaying/RadioStreamBuffer.swift | 2 +- 3 files changed, 138 insertions(+), 72 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 1eac76a..3461fa8 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -343,105 +343,143 @@ class AudioPlayer: NSObject, ObservableObject { private(set) var radioStreamURL: URL? #if os(iOS) - /// Switch radio playback from live to buffered file for scrubbing - private var isPlayingFromBuffer = false - - func radioSeekBack(to secondsFromStart: TimeInterval) { + /// When true, AVPlayer is playing a snapshot file rather than the live stream + @Published private(set) var isPlayingFromBuffer = false + /// Current playback position within the buffer timeline (0 = start of buffer, bufferSec = live edge) + @Published var radioBufferPlayheadSec: TimeInterval = 0 + + private var currentSnapshotURL: URL? + private var snapshotStatusObservation: NSKeyValueObservation? + private var snapshotEndObserver: Any? + + func radioSeekBack(to positionInBuffer: TimeInterval) { guard isRadioStream else { return } let buffer = RadioStreamBuffer.shared guard let srcURL = buffer.bufferPlaybackURL else { - alog("Radio seek: no buffer file") + alog("Radio seek: no buffer file yet") return } - RadioStreamBuffer.shared.isLive = false - let seekTime = max(0.1, secondsFromStart) + buffer.isLive = false + let seekTime = max(0.1, positionInBuffer) + let snapshotBufSec = buffer.estimatedBufferSeconds - // AVPlayer can't reliably seek within a file that's still being written to — - // it caches the file size at load time and may think the file is shorter than - // it is, or refuse seeks past the cached end. - // Fix: snapshot the buffer to a stable temp file, load that instead. - // The snapshot only needs to cover content up to seekTime + some headroom; - // we copy the whole thing so forward scrub also works without reloading. + // CRITICAL: use the correct audio extension (not .raw) so AVPlayer detects the format. + // .raw causes AVPlayer to fail format detection → status never becomes readyToPlay. + let ext = buffer.guessExtension() let snapURL = FileManager.default.temporaryDirectory - .appendingPathComponent("radio_snap_\(UUID().uuidString).raw") + .appendingPathComponent("radio_snap_\(UUID().uuidString).\(ext)") + + // Clean up any previous snapshot resources + snapshotStatusObservation?.invalidate() + snapshotStatusObservation = nil + if let obs = snapshotEndObserver { + NotificationCenter.default.removeObserver(obs) + snapshotEndObserver = nil + } + if let old = currentSnapshotURL { + try? FileManager.default.removeItem(at: old) + currentSnapshotURL = nil + } + do { try FileManager.default.copyItem(at: srcURL, to: snapURL) } catch { - alog("Radio seek: snapshot failed — \(error.localizedDescription)") + alog("Radio seek: snapshot copy failed — \(error.localizedDescription)") return } - + currentSnapshotURL = snapURL isPlayingFromBuffer = true + radioBufferPlayheadSec = seekTime + let asset = AVURLAsset(url: snapURL) let item = AVPlayerItem(asset: asset) - // Clean up the snap file once AVPlayer is done with it - NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { _ in - try? FileManager.default.removeItem(at: snapURL) + // Auto-return to live when snapshot playback reaches the end + snapshotEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main + ) { [weak self] _ in + self?.alog("Radio: snapshot reached end — returning to live") + self?.radioGoLive() } player?.replaceCurrentItem(with: item) - // Wait for item to be ready before seeking — immediate seek on a fresh item fails - let seekCMTime = CMTime(seconds: seekTime, preferredTimescale: 600) - Task { @MainActor in - // Poll readiness up to 3s - for _ in 0..<30 { - if item.status == .readyToPlay { break } - try? await Task.sleep(nanoseconds: 100_000_000) - } - guard item.status == .readyToPlay else { - self.alog("Radio seek: item not ready after 3s") - return - } - await withCheckedContinuation { cont in - item.seek(to: seekCMTime, toleranceBefore: .zero, toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600)) { _ in - cont.resume() + // KVO on status — more reliable than polling; fires immediately on failure too + snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in + guard let self else { return } + switch observedItem.status { + case .readyToPlay: + self.snapshotStatusObservation?.invalidate() + self.snapshotStatusObservation = nil + // Generous tolerance for raw audio without index tables + let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) + let tol = CMTime(seconds: 1, preferredTimescale: 600) + self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in + self?.player?.play() + self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live") } + case .failed: + self.snapshotStatusObservation?.invalidate() + self.snapshotStatusObservation = nil + self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")") + try? FileManager.default.removeItem(at: snapURL) + self.currentSnapshotURL = nil + self.radioGoLive() // fall back to live + default: + break } - self.player?.play() - self.alog("Radio seek: playing from \(String(format: "%.1f", seekTime))s in snapshot") } } - /// Jump back to live radio + /// Return to the live radio stream, discarding any snapshot func radioGoLive() { guard isRadioStream, let liveURL = radioStreamURL else { return } RadioStreamBuffer.shared.isLive = true isPlayingFromBuffer = false + radioBufferPlayheadSec = 0 + + snapshotStatusObservation?.invalidate() + snapshotStatusObservation = nil + if let obs = snapshotEndObserver { + NotificationCenter.default.removeObserver(obs) + snapshotEndObserver = nil + } + if let old = currentSnapshotURL { + try? FileManager.default.removeItem(at: old) + currentSnapshotURL = nil + } let asset = AVURLAsset(url: liveURL) let item = AVPlayerItem(asset: asset) player?.replaceCurrentItem(with: item) player?.play() - alog("Radio: back to live") + alog("Radio: ▶ live") } - /// Skip forward or backward within the timeshift buffer (seconds, negative = rewind). - /// Clamped to [0.1, bufferDuration]. Snaps to live if we reach the live edge. + /// Skip ±seconds within the buffer. Negative = rewind. Snaps to live at edge. func radioSkip(by seconds: TimeInterval) { guard isRadioStream else { return } let buffer = RadioStreamBuffer.shared let bufferSec = buffer.estimatedBufferSeconds guard bufferSec > 1 else { - alog("Radio skip: buffer too short (\(String(format: "%.1f", bufferSec))s)") + alog("Radio skip: not enough buffer (\(String(format: "%.1f", bufferSec))s)") return } + // Current position in buffer timeline let currentPos: TimeInterval - if isPlayingFromBuffer, let t = player?.currentTime(), t.isNumeric { - // We're in a snapshot — position is the seek time we used, not the live edge - currentPos = t.seconds + if isPlayingFromBuffer { + let t = player?.currentTime() + currentPos = (t?.isNumeric == true) ? t!.seconds : radioBufferPlayheadSec } else { - // At live edge — position is the current buffer length - currentPos = bufferSec + currentPos = bufferSec // at live edge } let newPos = currentPos + seconds - alog("Radio skip \(seconds > 0 ? "+" : "")\(Int(seconds))s: pos=\(String(format: "%.1f", currentPos))s → \(String(format: "%.1f", newPos))s (buf=\(String(format: "%.1f", bufferSec))s)") + alog("Radio skip \(seconds > 0 ? "+" : "")\(Int(seconds))s: \(String(format: "%.1f", currentPos))s → \(String(format: "%.1f", newPos))s (buf \(String(format: "%.1f", bufferSec))s)") - if newPos >= bufferSec - 1 { + if newPos >= bufferSec - 0.5 { radioGoLive() } else { radioSeekBack(to: max(0.1, newPos)) diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 25b4a39..c76d282 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -803,54 +803,82 @@ struct NowPlayingView: View { private var radioProgressBar: some View { VStack(spacing: 6) { if radioBuffer.isBuffering { - // Buffer active — full scrubbable seek bar that fills as buffer grows GeometryReader { geo in let bufferSec = radioBuffer.estimatedBufferSeconds let maxSec = radioBuffer.maxBufferDuration + + // fillPct: how much of the 20-min bar is filled let fillPct = min(max(bufferSec / maxSec, 0), 1.0) - // Live head position as fraction of the filled area - let livePct = fillPct // live edge is always the right edge of the fill - let scrubPct = isDraggingSlider ? dragPosition : livePct + + // Playhead position: + // - When live (not in snapshot): at the live edge = fillPct + // - When in snapshot: player.currentTime / maxSec + // - When dragging: use dragPosition + let playheadPct: CGFloat = { + if isDraggingSlider { return dragPosition } + if audioPlayer.isPlayingFromBuffer { + // playbackTime is updated from audioPlayer.currentTime by timePoller + return CGFloat(min(playbackTime / maxSec, fillPct)) + } + return fillPct // live edge + }() ZStack(alignment: .leading) { - // Track - Capsule().fill(Color.white.opacity(0.12)).frame(height: 4) - // Buffered fill - Capsule().fill(accentPink.opacity(0.35)) + // Track background + Capsule().fill(Color.white.opacity(0.1)).frame(height: 4) + // Buffer fill + Capsule().fill(accentPink.opacity(0.3)) .frame(width: geo.size.width * fillPct, height: 4) - // Playhead + // Playhead dot Circle() .fill(radioBuffer.isLive ? accentPink : Color.white) - .frame(width: isDraggingSlider ? 16 : 12, height: isDraggingSlider ? 16 : 12) - .offset(x: geo.size.width * scrubPct - (isDraggingSlider ? 8 : 6)) + .frame(width: isDraggingSlider ? 16 : 12, + height: isDraggingSlider ? 16 : 12) + .offset(x: geo.size.width * playheadPct - (isDraggingSlider ? 8 : 6)) .animation(.interactiveSpring(), value: isDraggingSlider) } .contentShape(Rectangle()) .gesture( - DragGesture(minimumDistance: 0) + // minimumDistance: 8 prevents a simple tap from triggering a seek + DragGesture(minimumDistance: 8) .onChanged { v in isDraggingSlider = true + // Clamp drag to the filled (available) region only let pct = min(max(v.location.x / geo.size.width, 0), fillPct) dragPosition = pct } .onEnded { v in isDraggingSlider = false let pct = min(max(v.location.x / geo.size.width, 0), fillPct) - let seekTime = maxSec * pct - audioPlayer.radioSeekBack(to: seekTime) + // Convert bar fraction → buffer position in seconds + // (pct * maxSec gives position within the 20-min timeline) + let seekPos = pct * maxSec + audioPlayer.radioSeekBack(to: seekPos) } ) } - .frame(height: 20) + .frame(height: 24) // Time labels HStack { - Text(isDraggingSlider - ? "-" + formatTime(radioBuffer.maxBufferDuration * (1.0 - dragPosition) * (radioBuffer.estimatedBufferSeconds / radioBuffer.maxBufferDuration)) - : (radioBuffer.isLive ? "LIVE" : "-" + formatTime(max(0, radioBuffer.estimatedBufferSeconds - radioBuffer.maxBufferDuration * (isDraggingSlider ? dragPosition : min(radioBuffer.estimatedBufferSeconds / radioBuffer.maxBufferDuration, 1.0))))) - ) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(radioBuffer.isLive ? accentPink : .gray) + if isDraggingSlider { + // Show how far behind live the drag position would be + let dragPosSec = dragPosition * radioBuffer.maxBufferDuration + let behindLive = max(0, radioBuffer.estimatedBufferSeconds - dragPosSec) + Text(behindLive < 1 ? "LIVE" : "-" + formatTime(behindLive)) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(behindLive < 1 ? accentPink : .gray) + } else if radioBuffer.isLive { + Text("LIVE") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(accentPink) + } else { + // How far behind live we are = bufferSec - current snapshot position + let behindLive = max(0, radioBuffer.estimatedBufferSeconds - playbackTime) + Text("-" + formatTime(behindLive)) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.gray) + } Spacer() @@ -859,9 +887,9 @@ struct NowPlayingView: View { .foregroundColor(.gray) } } else { - // No buffer — greyed static bar + // No buffer active — greyed static bar Capsule() - .fill(Color.white.opacity(0.12)) + .fill(Color.white.opacity(0.1)) .frame(height: 4) HStack { Text("LIVE") diff --git a/iOS/Views/NowPlaying/RadioStreamBuffer.swift b/iOS/Views/NowPlaying/RadioStreamBuffer.swift index 7eb8fcb..bc6c9fd 100644 --- a/iOS/Views/NowPlaying/RadioStreamBuffer.swift +++ b/iOS/Views/NowPlaying/RadioStreamBuffer.swift @@ -360,7 +360,7 @@ class RadioStreamBuffer: NSObject, ObservableObject, URLSessionDataDelegate { } } - private func guessExtension() -> String { + func guessExtension() -> String { // Default to mp3 for radio streams — most common return "mp3" }