Update from NavidromePlayer.zip (2026-04-04 19:00)

This commit is contained in:
Dallas Groot 2026-04-04 19:00:31 -07:00
parent cf84c60159
commit df70a99279
3 changed files with 138 additions and 72 deletions

View file

@ -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))

View file

@ -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")

View file

@ -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"
}