Update from NavidromePlayer.zip (2026-04-04 18:41)
This commit is contained in:
parent
2f6cbb9e63
commit
6ac81a8dc1
2 changed files with 87 additions and 33 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue