recover stalled native skips

This commit is contained in:
dirtydishes 2026-05-25 16:13:09 -04:00
parent 4ca0151f1a
commit 3fde2d7b34
2 changed files with 153 additions and 14 deletions

View file

@ -6,6 +6,9 @@ import MobileVLCKit
final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private static let seekBufferMilliseconds = 30_000 private static let seekBufferMilliseconds = 30_000
private static let stalledJumpRecoveryDelay: TimeInterval = 3.0
private static let stalledJumpProgressTolerance: TimeInterval = 0.75
private static let stalledJumpTargetTolerance: TimeInterval = 2.0
static var isAvailable: Bool { static var isAvailable: Bool {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
@ -24,8 +27,11 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private let mediaPlayer = VLCMediaPlayer() private let mediaPlayer = VLCMediaPlayer()
private var currentRequest: NativePlaybackRequest?
private var recoveryGeneration = 0
#endif #endif
private var attachedSubtitleURLs = Set<URL>() private var attachedSubtitleURLs = Set<URL>()
private var attachedSubtitleCandidates: [SubtitleCandidate] = []
private var didAutoSelectSubtitleTrack = false private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32? private var autoSelectedSubtitleTrackID: Int32?
@ -50,7 +56,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func play(request: NativePlaybackRequest) { func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
currentRequest = request
recoveryGeneration += 1
attachedSubtitleURLs.removeAll() attachedSubtitleURLs.removeAll()
attachedSubtitleCandidates.removeAll()
didAutoSelectSubtitleTrack = false didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil autoSelectedSubtitleTrackID = nil
@ -58,20 +67,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
hasPendingExternalSubtitleSelection = false hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll() pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll()
let media = VLCMedia(url: request.playbackURL) mediaPlayer.media = configuredMedia(for: request)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
configureSeekBuffer(for: media)
mediaPlayer.media = media
#if DEBUG #if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) seekBufferMilliseconds=\(Self.seekBufferMilliseconds)") print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) seekBufferMilliseconds=\(Self.seekBufferMilliseconds)")
#endif #endif
@ -99,6 +95,25 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
cachingOptions.forEach { media.addOption($0) } cachingOptions.forEach { media.addOption($0) }
} }
private func configuredMedia(for request: NativePlaybackRequest, startTime: TimeInterval? = nil) -> VLCMedia {
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
if let startTime {
media.addOption(":start-time=\(Int(startTime.rounded()))")
}
configureSeekBuffer(for: media)
return media
}
#endif #endif
func pause() { func pause() {
@ -138,6 +153,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
mediaPlayer.position = Float(nextTime / duration) mediaPlayer.position = Float(nextTime / duration)
mediaPlayer.play() mediaPlayer.play()
scheduleStalledJumpRecovery(from: currentTime, targetTime: nextTime)
#if DEBUG #if DEBUG
schedulePostSeekDiagnostics(label: "jump", expectedTime: nextTime) schedulePostSeekDiagnostics(label: "jump", expectedTime: nextTime)
#endif #endif
@ -199,6 +215,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.drawable = nil mediaPlayer.drawable = nil
mediaPlayer.media = nil mediaPlayer.media = nil
currentRequest = nil
recoveryGeneration += 1
#endif #endif
} }
@ -310,6 +328,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
return return
} }
attachedSubtitleURLs.insert(candidate.url) attachedSubtitleURLs.insert(candidate.url)
attachedSubtitleCandidates.append(candidate)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs) externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true hasPendingExternalSubtitleSelection = true
pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate)) pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))
@ -370,6 +389,78 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
} }
private func scheduleStalledJumpRecovery(from originalTime: TimeInterval, targetTime: TimeInterval) {
recoveryGeneration += 1
let generation = recoveryGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + Self.stalledJumpRecoveryDelay) { [weak self] in
self?.recoverStalledJumpIfNeeded(
generation: generation,
originalTime: originalTime,
targetTime: targetTime
)
}
}
private func recoverStalledJumpIfNeeded(
generation: Int,
originalTime: TimeInterval,
targetTime: TimeInterval
) {
guard generation == recoveryGeneration,
let request = currentRequest,
mediaPlayer.state == .buffering else {
return
}
let hasMadeProgress = abs(currentTime - originalTime) > Self.stalledJumpProgressTolerance
let hasReachedTarget = abs(currentTime - targetTime) <= Self.stalledJumpTargetTolerance
guard !hasMadeProgress && !hasReachedTarget else {
return
}
let subtitleCandidates = attachedSubtitleCandidates
let selectedAudioTrackID = mediaPlayer.currentAudioTrackIndex
let selectedSubtitleTrackID = mediaPlayer.currentVideoSubTitleIndex
let wasUserSubtitleSelection = didUserSelectSubtitleTrack
#if DEBUG
print("[DreamioVLC] stalled jump recovery target=\(targetTime) original=\(originalTime) current=\(currentTime) position=\(mediaPlayer.position)")
#endif
recoveryGeneration += 1
attachedSubtitleURLs.removeAll()
attachedSubtitleCandidates.removeAll()
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didUserSelectSubtitleTrack = wasUserSubtitleSelection
mediaPlayer.stop()
mediaPlayer.media = configuredMedia(for: request, startTime: targetTime)
mediaPlayer.play()
_ = attachSubtitles(subtitleCandidates)
scheduleTrackRecovery(audioTrackID: selectedAudioTrackID, subtitleTrackID: selectedSubtitleTrackID)
}
private func scheduleTrackRecovery(audioTrackID: Int32, subtitleTrackID: Int32) {
[0.4, 1.2, 2.5].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else {
return
}
if audioTrackID >= 0,
self.audioTracks.contains(where: { $0.id == audioTrackID }) {
self.mediaPlayer.currentAudioTrackIndex = audioTrackID
}
if subtitleTrackID >= 0,
self.subtitleTracks.contains(where: { $0.id == subtitleTrackID }) {
self.mediaPlayer.currentVideoSubTitleIndex = subtitleTrackID
}
self.onAudioTracksChange?()
self.onSubtitleTracksChange?()
}
}
}
#if DEBUG #if DEBUG
private func logPlaybackSnapshot(reason: String) { private func logPlaybackSnapshot(reason: String) {
print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) isSeekable=\(mediaPlayer.isSeekable) time=\(currentTime) duration=\(duration) position=\(mediaPlayer.position) selectedAudio=\(mediaPlayer.currentAudioTrackIndex) selectedSubtitle=\(mediaPlayer.currentVideoSubTitleIndex)") print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) isSeekable=\(mediaPlayer.isSeekable) time=\(currentTime) duration=\(duration) position=\(mediaPlayer.position) selectedAudio=\(mediaPlayer.currentAudioTrackIndex) selectedSubtitle=\(mediaPlayer.currentVideoSubTitleIndex)")

View file

@ -385,6 +385,54 @@ index c3c2318..0fa779a 100644
<h3>Related issues or PRs</h3> <h3>Related issues or PRs</h3>
<p>Related Beads issue: <code>dreamio-3yb</code>. This is a diagnostic update to the same native seek-buffer investigation. The diff is shown as a plain fallback because <code>@pierre/diffs/ssr</code> is not installed in this workspace.</p> <p>Related Beads issue: <code>dreamio-3yb</code>. This is a diagnostic update to the same native seek-buffer investigation. The diff is shown as a plain fallback because <code>@pierre/diffs/ssr</code> is not installed in this workspace.</p>
</section> </section>
<section>
<h2>New Changes as of 2026-05-25 16:13 EDT</h2>
<h3>Summary of changes</h3>
<p>Device logs showed VLC accepted the jump request but kept reporting the old timestamp and position while buffering. The backend now detects that stalled jump pattern and performs a native-only recovery by reopening the same media with VLC's <code>:start-time</code> option set to the requested target timestamp.</p>
<h3>Why this change was made</h3>
<p>The seek buffer and alternate seek APIs did not make libVLC apply the seek on this direct stream. Reopening the same media at the target time gives VLC a fresh input pipeline while keeping the behavior inside <code>VLCNativePlaybackBackend</code> and without adding player UI or protocol settings.</p>
<h3>Code diffs</h3>
<pre><code>diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
@@
+ private static let stalledJumpRecoveryDelay: TimeInterval = 3.0
+ private static let stalledJumpProgressTolerance: TimeInterval = 0.75
+ private static let stalledJumpTargetTolerance: TimeInterval = 2.0
@@
+ private var currentRequest: NativePlaybackRequest?
+ private var recoveryGeneration = 0
@@
+ if let startTime {
+ media.addOption(":start-time=\(Int(startTime.rounded()))")
+ }
@@
mediaPlayer.position = Float(nextTime / duration)
mediaPlayer.play()
+ scheduleStalledJumpRecovery(from: currentTime, targetTime: nextTime)
@@
+ guard generation == recoveryGeneration,
+ let request = currentRequest,
+ mediaPlayer.state == .buffering else {
+ return
+ }
+
+ let hasMadeProgress = abs(currentTime - originalTime) > Self.stalledJumpProgressTolerance
+ let hasReachedTarget = abs(currentTime - targetTime) <= Self.stalledJumpTargetTolerance
+ guard !hasMadeProgress && !hasReachedTarget else {
+ return
+ }
+
+ mediaPlayer.stop()
+ mediaPlayer.media = configuredMedia(for: request, startTime: targetTime)
+ mediaPlayer.play()
+ _ = attachSubtitles(subtitleCandidates)
+ scheduleTrackRecovery(audioTrackID: selectedAudioTrackID, subtitleTrackID: selectedSubtitleTrackID)</code></pre>
<h3>Related issues or PRs</h3>
<p>Related Beads issue: <code>dreamio-3yb</code>. This remains part of the same native playback seek-buffer branch. The branch now includes a fallback for streams where VLC's in-place seek does not take effect.</p>
</section>
</main> </main>
</body> </body>
</html> </html>