Update from NavidromePlayer.zip (2026-04-04 19:00)
This commit is contained in:
parent
cf84c60159
commit
df70a99279
3 changed files with 138 additions and 72 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue