Update from NavidromePlayer.zip (2026-04-04 18:41)

This commit is contained in:
Dallas Groot 2026-04-04 18:41:21 -07:00
parent 2f6cbb9e63
commit 6ac81a8dc1
2 changed files with 87 additions and 33 deletions

View file

@ -347,71 +347,106 @@ class AudioPlayer: NSObject, ObservableObject {
private var isPlayingFromBuffer = false
func radioSeekBack(to secondsFromStart: TimeInterval) {
guard isRadioStream, let bufferURL = RadioStreamBuffer.shared.bufferPlaybackURL else { return }
guard isRadioStream else { return }
let buffer = RadioStreamBuffer.shared
guard let srcURL = buffer.bufferPlaybackURL else {
alog("Radio seek: no buffer file")
return
}
RadioStreamBuffer.shared.isLive = false
// Guard: don't seek to exactly 0 can break partial file reads
let seekTime = max(0.1, secondsFromStart)
if isPlayingFromBuffer {
// Already on buffer just seek within it
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
player?.seek(to: cmTime) { [weak self] _ in
self?.player?.play()
// 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.
let snapURL = FileManager.default.temporaryDirectory
.appendingPathComponent("radio_snap_\(UUID().uuidString).raw")
do {
try FileManager.default.copyItem(at: srcURL, to: snapURL)
} catch {
alog("Radio seek: snapshot failed — \(error.localizedDescription)")
return
}
isPlayingFromBuffer = true
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)
}
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)
}
} else {
// First switch: swap AVPlayer to the buffer file
isPlayingFromBuffer = true
let asset = AVURLAsset(url: bufferURL)
let item = AVPlayerItem(asset: asset)
player?.replaceCurrentItem(with: item)
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
player?.seek(to: cmTime) { [weak self] _ in
self?.player?.play()
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()
}
}
self.player?.play()
self.alog("Radio seek: playing from \(String(format: "%.1f", seekTime))s in snapshot")
}
}
/// Jump back to live radio
func radioGoLive() {
guard isRadioStream, let liveURL = radioStreamURL else { return }
RadioStreamBuffer.shared.isLive = true
isPlayingFromBuffer = false
let asset = AVURLAsset(url: liveURL)
let item = AVPlayerItem(asset: asset)
player?.replaceCurrentItem(with: item)
player?.play()
alog("Radio: back to live")
}
/// Skip forward or backward within the timeshift buffer (seconds, negative = rewind).
/// Clamped to [0, bufferDuration]. Snaps to live if we reach the live edge.
/// Clamped to [0.1, bufferDuration]. Snaps to live if we reach the live edge.
func radioSkip(by seconds: TimeInterval) {
guard isRadioStream else { return }
let buffer = RadioStreamBuffer.shared
let bufferSec = buffer.estimatedBufferSeconds
guard bufferSec > 0 else { return }
guard bufferSec > 1 else {
alog("Radio skip: buffer too short (\(String(format: "%.1f", bufferSec))s)")
return
}
// Current position within the buffer file
let currentPos: TimeInterval
if isPlayingFromBuffer, let t = player?.currentTime() {
currentPos = t.seconds.isFinite ? t.seconds : bufferSec
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
} else {
currentPos = bufferSec // at live edge
// At live edge position is the current buffer length
currentPos = bufferSec
}
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)")
if newPos >= bufferSec {
// Reached (or beyond) live edge snap to live
if newPos >= bufferSec - 1 {
radioGoLive()
} else {
radioSeekBack(to: max(0.1, newPos))
}
}
#endif
#if os(iOS)
/// Set now playing artwork from a UIImage (for radio station custom covers)

View file

@ -443,6 +443,9 @@ class CompanionPushClient: ObservableObject {
private var webSocketTask: URLSessionWebSocketTask?
private var pingTimer: Timer?
private var reconnectTask: Task<Void, Never>?
private var reconnectDelay: TimeInterval = 5 // starts at 5s
private let maxReconnectDelay: TimeInterval = 120 // caps at 2 min
private var reconnectAttempts = 0
private init() {}
@ -456,6 +459,9 @@ class CompanionPushClient: ObservableObject {
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
// Reset backoff on fresh connect
reconnectDelay = 5
reconnectAttempts = 0
DebugLogger.shared.log("Push: connecting to \(wsURL)", category: "Companion")
listen()
@ -486,7 +492,14 @@ class CompanionPushClient: ObservableObject {
self.listen() // Keep listening
case .failure(let error):
DebugLogger.shared.log("Push: disconnected — \(error.localizedDescription)", category: "Companion")
// Only log and reschedule if Companion is still enabled
// avoids spam when the server is unreachable or disabled
guard CompanionSettings.shared.isEnabled else { return }
// Suppress the extremely common "Socket is not connected" noise
// after the first attempt it fills the console during backoff
if reconnectAttempts == 0 {
DebugLogger.shared.log("Push: disconnected — \(error.localizedDescription)", category: "Companion")
}
DispatchQueue.main.async {
self.isConnected = false
self.scheduleReconnect()
@ -546,8 +559,14 @@ class CompanionPushClient: ObservableObject {
}
private func scheduleReconnect() {
guard CompanionSettings.shared.isEnabled else { return }
reconnectAttempts += 1
let delay = reconnectDelay
// Exponential backoff: 5 10 20 40 80 120 120...
reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay)
DebugLogger.shared.log("Push: reconnect #\(reconnectAttempts) in \(Int(delay))s", category: "Companion")
reconnectTask = Task {
try? await Task.sleep(for: .seconds(5))
try? await Task.sleep(for: .seconds(delay))
await MainActor.run { self.connect() }
}
}