Harden VLC playback controls

This commit is contained in:
dirtydishes 2026-05-26 23:47:06 -04:00
parent 6ced219906
commit 62366c0e25
7 changed files with 583 additions and 16 deletions

View file

@ -40,3 +40,4 @@
{"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}} {"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}}
{"id":"int-fc9ecdb1","kind":"field_change","created_at":"2026-05-27T01:50:32.02792Z","actor":"dirtydishes","issue_id":"dreamio-ee1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed baseline UX audit in docs/native-player-ux-audit.md"}} {"id":"int-fc9ecdb1","kind":"field_change","created_at":"2026-05-27T01:50:32.02792Z","actor":"dirtydishes","issue_id":"dreamio-ee1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed baseline UX audit in docs/native-player-ux-audit.md"}}
{"id":"int-c8a14c48","kind":"field_change","created_at":"2026-05-27T01:56:02.08139Z","actor":"dirtydishes","issue_id":"dreamio-060","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native player Liquid Glass UX improvements and validated simulator build."}} {"id":"int-c8a14c48","kind":"field_change","created_at":"2026-05-27T01:56:02.08139Z","actor":"dirtydishes","issue_id":"dreamio-060","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native player Liquid Glass UX improvements and validated simulator build."}}
{"id":"int-8c109835","kind":"field_change","created_at":"2026-05-27T03:47:00.090296Z","actor":"dirtydishes","issue_id":"dreamio-e3u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation."}}

View file

@ -46,6 +46,7 @@
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-e3u","title":"Harden VLC play pause synchronization","description":"Implement state-aware MobileVLCKit play/pause handling, instrumentation, readiness gating, conservative caching, and validation for pause/audio lag.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T03:43:10Z","created_by":"dirtydishes","updated_at":"2026-05-27T03:47:00Z","started_at":"2026-05-27T03:43:55Z","closed_at":"2026-05-27T03:47:00Z","close_reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ee1","title":"Audit native player UX baseline","description":"Audit the existing native player controls and document current user experience strengths, gaps, and implementation constraints before making UI changes.","acceptance_criteria":"Current NativePlayerViewController controls are reviewed; backend constraints are summarized; UX improvement opportunities are documented.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:49:46Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:50:32Z","started_at":"2026-05-27T01:49:48Z","closed_at":"2026-05-27T01:50:32Z","close_reason":"Completed baseline UX audit in docs/native-player-ux-audit.md","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ee1","title":"Audit native player UX baseline","description":"Audit the existing native player controls and document current user experience strengths, gaps, and implementation constraints before making UI changes.","acceptance_criteria":"Current NativePlayerViewController controls are reviewed; backend constraints are summarized; UX improvement opportunities are documented.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:49:46Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:50:32Z","started_at":"2026-05-27T01:49:48Z","closed_at":"2026-05-27T01:50:32Z","close_reason":"Completed baseline UX audit in docs/native-player-ux-audit.md","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -34,6 +34,36 @@ protocol NativePlaybackBackend: AnyObject {
func stop() func stop()
} }
enum NativePlaybackToggleState {
case opening
case buffering
case playing
case paused
case stopped
case ended
case error
case unknown
}
enum NativePlaybackToggleAction {
case play
case pause
case waitForTransition
}
enum NativePlaybackTogglePolicy {
static func action(for state: NativePlaybackToggleState) -> NativePlaybackToggleAction {
switch state {
case .playing, .buffering:
return .pause
case .paused, .stopped, .ended, .error:
return .play
case .opening, .unknown:
return .waitForTransition
}
}
}
enum NativePlaybackError: LocalizedError { enum NativePlaybackError: LocalizedError {
case backendUnavailable case backendUnavailable
case startupTimedOut case startupTimedOut

View file

@ -288,17 +288,17 @@ final class NativePlayerViewController: UIViewController {
} }
backend.onStateChange = { [weak self] in backend.onStateChange = { [weak self] in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.refreshControls() self?.refreshProgressControls()
} }
} }
backend.onSubtitleTracksChange = { [weak self] in backend.onSubtitleTracksChange = { [weak self] in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.refreshControls() self?.refreshTrackMenus()
} }
} }
backend.onAudioTracksChange = { [weak self] in backend.onAudioTracksChange = { [weak self] in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.refreshControls() self?.refreshTrackMenus()
} }
} }
} }
@ -646,26 +646,33 @@ final class NativePlayerViewController: UIViewController {
private func startProgressUpdates() { private func startProgressUpdates() {
progressTimer?.invalidate() progressTimer?.invalidate()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshControls() self?.refreshProgressControls()
} }
} }
private func refreshControls() { private func refreshControls() {
let audioTracks = backend.audioTracks refreshProgressControls()
let subtitleTracks = backend.subtitleTracks refreshTrackMenus()
}
private func refreshProgressControls() {
let isSeekable = backend.isSeekable
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
scrubber.isEnabled = backend.isSeekable scrubber.isEnabled = isSeekable
jumpBackButton.isEnabled = backend.isSeekable jumpBackButton.isEnabled = isSeekable
jumpForwardButton.isEnabled = backend.isSeekable jumpForwardButton.isEnabled = isSeekable
updateAudioMenuIfNeeded(audioTracks: audioTracks)
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining" scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"
if !isScrubbing { if !isScrubbing {
scrubber.value = backend.position scrubber.value = backend.position
} }
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = isSeekable ? 1 : 0.45 }
}
private func refreshTrackMenus() {
updateAudioMenuIfNeeded(audioTracks: backend.audioTracks)
updateCaptionsMenuIfNeeded(subtitleTracks: backend.subtitleTracks)
} }
private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) { private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) {

View file

@ -31,6 +31,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private var hasPendingExternalSubtitleSelection = false private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = [] private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:] private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
private var didReportReadyForCurrentMedia = false
private var lastToggleDate = Date.distantPast
private let minimumToggleInterval: TimeInterval = 0.35
override init() { override init() {
super.init() super.init()
@ -56,6 +59,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
hasPendingExternalSubtitleSelection = false hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll() pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL) let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers let headerValue = request.headers
.map { "\($0.key): \($0.value)" } .map { "\($0.key): \($0.value)" }
@ -67,12 +72,18 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
if !headerValue.isEmpty { if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)") media.addOption(":http-header=\(headerValue)")
} }
addConservativePlaybackOptions(to: media)
mediaPlayer.currentAudioPlaybackDelay = 0
mediaPlayer.media = media mediaPlayer.media = media
#if DEBUG #if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) options=conservative-low-latency")
logPlaybackSnapshot(reason: "before-initial-play")
#endif #endif
mediaPlayer.play() mediaPlayer.play()
#if DEBUG
logPlaybackSnapshot(reason: "after-initial-play-command")
#endif
#else #else
onFailure?(NativePlaybackError.backendUnavailable) onFailure?(NativePlaybackError.backendUnavailable)
#endif #endif
@ -80,18 +91,65 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func play() { func play() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
#if DEBUG
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play() mediaPlayer.play()
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
#endif
#endif #endif
} }
func pause() { func pause() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
let state = mediaPlayer.state
#if DEBUG
logPlaybackSnapshot(reason: "before-pause canPause=\(mediaPlayer.canPause) pauseable=\(isPauseableState(state))")
#endif
guard mediaPlayer.canPause, isPauseableState(state) else {
#if DEBUG
print("[DreamioVLC] pause skipped state=\(stateName(state)) canPause=\(mediaPlayer.canPause)")
#endif
return
}
mediaPlayer.pause() mediaPlayer.pause()
#if DEBUG
logPlaybackSnapshot(reason: "after-pause-command")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.logPlaybackSnapshot(reason: "pause-follow-up-250ms")
}
#endif
#endif #endif
} }
func togglePlayPause() { func togglePlayPause() {
#if canImport(MobileVLCKit)
let now = Date()
guard now.timeIntervalSince(lastToggleDate) >= minimumToggleInterval else {
#if DEBUG
print("[DreamioVLC] toggle skipped rapid-repeat elapsed=\(String(format: "%.3f", now.timeIntervalSince(lastToggleDate)))")
#endif
return
}
lastToggleDate = now
let state = playbackToggleState(for: mediaPlayer.state)
let action = NativePlaybackTogglePolicy.action(for: state)
#if DEBUG
logPlaybackSnapshot(reason: "toggle action=\(action) mappedState=\(state)")
#endif
switch action {
case .play:
play()
case .pause:
pause()
case .waitForTransition:
break
}
#else
isPlaying ? pause() : play() isPlaying ? pause() : play()
#endif
} }
func seek(to position: Float) { func seek(to position: Float) {
@ -119,8 +177,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
logAudioTracks(reason: "before-select-\(id)") logAudioTracks(reason: "before-select-\(id)")
#endif #endif
mediaPlayer.currentAudioTrackIndex = id mediaPlayer.currentAudioTrackIndex = id
mediaPlayer.currentAudioPlaybackDelay = 0
#if DEBUG #if DEBUG
logAudioTracks(reason: "after-select-\(id)") logAudioTracks(reason: "after-select-\(id)")
logPlaybackSnapshot(reason: "after-audio-select-\(id)")
#endif #endif
onAudioTracksChange?() onAudioTracksChange?()
#endif #endif
@ -165,6 +225,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func stop() { func stop() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
#if DEBUG
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.drawable = nil mediaPlayer.drawable = nil
mediaPlayer.media = nil mediaPlayer.media = nil
@ -269,6 +333,52 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private func addConservativePlaybackOptions(to media: VLCMedia) {
[
":network-caching=1000",
":file-caching=1000",
":live-caching=1000",
":clock-jitter=0"
].forEach { media.addOption($0) }
}
private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
switch state {
case .playing, .buffering:
return true
default:
return false
}
}
private func playbackToggleState(for state: VLCMediaPlayerState) -> NativePlaybackToggleState {
switch state {
case .opening:
return .opening
case .buffering:
return .buffering
case .playing:
return .playing
case .paused:
return .paused
case .stopped:
return .stopped
case .ended:
return .ended
case .error:
return .error
default:
return .unknown
}
}
#if DEBUG
private func logPlaybackSnapshot(reason: String) {
let mediaLength = mediaPlayer.media?.length.intValue ?? 0
print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) canPause=\(mediaPlayer.canPause) seekable=\(mediaPlayer.isSeekable) currentTime=\(String(format: "%.3f", currentTime)) duration=\(String(format: "%.3f", TimeInterval(max(0, mediaLength)) / 1000)) position=\(String(format: "%.4f", mediaPlayer.position)) audioDelay=\(mediaPlayer.currentAudioPlaybackDelay) readyReported=\(didReportReadyForCurrentMedia)")
}
#endif
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int { private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0 var attachedCount = 0
var duplicateCount = 0 var duplicateCount = 0
@ -430,17 +540,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) { func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG #if DEBUG
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") logPlaybackSnapshot(reason: "state-change")
#endif #endif
switch mediaPlayer.state { switch mediaPlayer.state {
case .buffering, .playing: case .buffering, .playing:
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state)) reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
onReady?() reportReadyIfNeeded()
onStateChange?() onStateChange?()
onAudioTracksChange?()
case .error: case .error:
onFailure?(NativePlaybackError.playbackFailed) onFailure?(NativePlaybackError.playbackFailed)
case .paused, .stopped, .ended: case .paused, .stopped, .ended:
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.logPlaybackSnapshot(reason: "inactive-state-follow-up-250ms")
}
#endif
onStateChange?() onStateChange?()
case .esAdded: case .esAdded:
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded") selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
@ -455,6 +569,22 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
} }
} }
private func reportReadyIfNeeded() {
guard !didReportReadyForCurrentMedia else {
#if DEBUG
print("[DreamioVLC] ready skipped already-reported state=\(stateName(mediaPlayer.state))")
#endif
return
}
didReportReadyForCurrentMedia = true
#if DEBUG
print("[DreamioVLC] ready reported state=\(stateName(mediaPlayer.state))")
#endif
onReady?()
onAudioTracksChange?()
onSubtitleTracksChange?()
}
private func stateName(_ state: VLCMediaPlayerState) -> String { private func stateName(_ state: VLCMediaPlayerState) -> String {
switch state { switch state {
case .opening: case .opening:

View file

@ -24,6 +24,7 @@ struct StreamResolverTests {
testSubtitleDisplayNameNormalization() testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone() testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
print("StreamResolverTests passed") print("StreamResolverTests passed")
} }
@ -505,6 +506,16 @@ struct StreamResolverTests {
assertEqual(options.map(\.name), ["None", "English", "Commentary"]) assertEqual(options.map(\.name), ["None", "English", "Commentary"])
} }
private static func testNativePlaybackTogglePolicy() {
assertEqual(NativePlaybackTogglePolicy.action(for: .playing), .pause)
assertEqual(NativePlaybackTogglePolicy.action(for: .buffering), .pause)
assertEqual(NativePlaybackTogglePolicy.action(for: .paused), .play)
assertEqual(NativePlaybackTogglePolicy.action(for: .stopped), .play)
assertEqual(NativePlaybackTogglePolicy.action(for: .ended), .play)
assertEqual(NativePlaybackTogglePolicy.action(for: .opening), .waitForTransition)
assertEqual(NativePlaybackTogglePolicy.action(for: .unknown), .waitForTransition)
}
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
} }

File diff suppressed because one or more lines are too long