mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Harden VLC playback controls
This commit is contained in:
parent
6ced219906
commit
62366c0e25
7 changed files with 583 additions and 16 deletions
|
|
@ -34,6 +34,36 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
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 {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
|
|
|
|||
|
|
@ -288,17 +288,17 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
backend.onStateChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
self?.refreshProgressControls()
|
||||
}
|
||||
}
|
||||
backend.onSubtitleTracksChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
self?.refreshTrackMenus()
|
||||
}
|
||||
}
|
||||
backend.onAudioTracksChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
self?.refreshTrackMenus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -646,26 +646,33 @@ final class NativePlayerViewController: UIViewController {
|
|||
private func startProgressUpdates() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.refreshControls()
|
||||
self?.refreshProgressControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshControls() {
|
||||
let audioTracks = backend.audioTracks
|
||||
let subtitleTracks = backend.subtitleTracks
|
||||
refreshProgressControls()
|
||||
refreshTrackMenus()
|
||||
}
|
||||
|
||||
private func refreshProgressControls() {
|
||||
let isSeekable = backend.isSeekable
|
||||
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
scrubber.isEnabled = backend.isSeekable
|
||||
jumpBackButton.isEnabled = backend.isSeekable
|
||||
jumpForwardButton.isEnabled = backend.isSeekable
|
||||
updateAudioMenuIfNeeded(audioTracks: audioTracks)
|
||||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||
scrubber.isEnabled = isSeekable
|
||||
jumpBackButton.isEnabled = isSeekable
|
||||
jumpForwardButton.isEnabled = isSeekable
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||
scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"
|
||||
if !isScrubbing {
|
||||
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]) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
private var hasPendingExternalSubtitleSelection = false
|
||||
private var pendingExternalSubtitleDisplayNames: [String] = []
|
||||
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
|
||||
private var didReportReadyForCurrentMedia = false
|
||||
private var lastToggleDate = Date.distantPast
|
||||
private let minimumToggleInterval: TimeInterval = 0.35
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
|
@ -56,6 +59,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
hasPendingExternalSubtitleSelection = false
|
||||
pendingExternalSubtitleDisplayNames.removeAll()
|
||||
externalSubtitleDisplayNamesByTrackID.removeAll()
|
||||
didReportReadyForCurrentMedia = false
|
||||
lastToggleDate = .distantPast
|
||||
let media = VLCMedia(url: request.playbackURL)
|
||||
let headerValue = request.headers
|
||||
.map { "\($0.key): \($0.value)" }
|
||||
|
|
@ -67,12 +72,18 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
if !headerValue.isEmpty {
|
||||
media.addOption(":http-header=\(headerValue)")
|
||||
}
|
||||
addConservativePlaybackOptions(to: media)
|
||||
mediaPlayer.currentAudioPlaybackDelay = 0
|
||||
|
||||
mediaPlayer.media = media
|
||||
#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
|
||||
mediaPlayer.play()
|
||||
#if DEBUG
|
||||
logPlaybackSnapshot(reason: "after-initial-play-command")
|
||||
#endif
|
||||
#else
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
#endif
|
||||
|
|
@ -80,18 +91,65 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
|
||||
func play() {
|
||||
#if canImport(MobileVLCKit)
|
||||
#if DEBUG
|
||||
logPlaybackSnapshot(reason: "before-play")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
#if DEBUG
|
||||
logPlaybackSnapshot(reason: "after-play-command")
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
func pause() {
|
||||
#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()
|
||||
#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
|
||||
}
|
||||
|
||||
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()
|
||||
#endif
|
||||
}
|
||||
|
||||
func seek(to position: Float) {
|
||||
|
|
@ -119,8 +177,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
logAudioTracks(reason: "before-select-\(id)")
|
||||
#endif
|
||||
mediaPlayer.currentAudioTrackIndex = id
|
||||
mediaPlayer.currentAudioPlaybackDelay = 0
|
||||
#if DEBUG
|
||||
logAudioTracks(reason: "after-select-\(id)")
|
||||
logPlaybackSnapshot(reason: "after-audio-select-\(id)")
|
||||
#endif
|
||||
onAudioTracksChange?()
|
||||
#endif
|
||||
|
|
@ -165,6 +225,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
|
||||
func stop() {
|
||||
#if canImport(MobileVLCKit)
|
||||
#if DEBUG
|
||||
logPlaybackSnapshot(reason: "before-stop")
|
||||
#endif
|
||||
didReportReadyForCurrentMedia = false
|
||||
mediaPlayer.stop()
|
||||
mediaPlayer.drawable = nil
|
||||
mediaPlayer.media = nil
|
||||
|
|
@ -269,6 +333,52 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
}
|
||||
|
||||
#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 {
|
||||
var attachedCount = 0
|
||||
var duplicateCount = 0
|
||||
|
|
@ -430,17 +540,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
|
||||
logPlaybackSnapshot(reason: "state-change")
|
||||
#endif
|
||||
switch mediaPlayer.state {
|
||||
case .buffering, .playing:
|
||||
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
|
||||
onReady?()
|
||||
reportReadyIfNeeded()
|
||||
onStateChange?()
|
||||
onAudioTracksChange?()
|
||||
case .error:
|
||||
onFailure?(NativePlaybackError.playbackFailed)
|
||||
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?()
|
||||
case .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 {
|
||||
switch state {
|
||||
case .opening:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue