Improve native player control experience

This commit is contained in:
dirtydishes 2026-05-26 21:56:05 -04:00
parent dbe9f1ca26
commit ddfa22f004
4 changed files with 531 additions and 59 deletions

View file

@ -11,8 +11,35 @@ final class NativePlayerViewController: UIViewController {
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
private let bottomScrimLayer = CAGradientLayer()
var onDismiss: (() -> Void)?
private let loadingContainer: UIVisualEffectView = {
let view = NativePlayerViewController.glassPanel(cornerRadius: 24)
view.isHidden = false
return view
}()
private let loadingStack: UIStackView = {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 12
return stack
}()
private let loadingTextLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Opening stream…"
label.textColor = .white
label.font = .preferredFont(forTextStyle: .subheadline)
label.adjustsFontForContentSizeCategory = true
return label
}()
private let loadingView: UIActivityIndicatorView = {
let view = UIActivityIndicatorView(style: .large)
view.translatesAutoresizingMaskIntoConstraints = false
@ -26,22 +53,16 @@ final class NativePlayerViewController: UIViewController {
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 18
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
button.layer.cornerRadius = 22
button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = "Close"
button.accessibilityHint = "Closes native playback and returns to Stremio."
return button
}()
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 22
view.clipsToBounds = true
view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
view.layer.borderWidth = 1
return view
}()
private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26)
private let tapSurfaceView: UIView = {
let view = UIView()
@ -50,11 +71,13 @@ final class NativePlayerViewController: UIViewController {
return view
}()
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause", pointSize: 24)
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Track")
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Subtitles")
private let centerPlayPauseButton = NativePlayerViewController.iconButton(systemName: "play.fill", label: "Toggle Playback", pointSize: 34)
private let elapsedLabel: UILabel = {
let label = UILabel()
@ -88,17 +111,50 @@ final class NativePlayerViewController: UIViewController {
return slider
}()
private let failureLabel: UILabel = {
private let scrubTimeBubble: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.isHidden = true
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold)
label.backgroundColor = UIColor.black.withAlphaComponent(0.56)
label.layer.cornerRadius = 14
label.clipsToBounds = true
label.alpha = 0
return label
}()
private let failureContainer: UIVisualEffectView = {
let view = NativePlayerViewController.glassPanel(cornerRadius: 28)
view.isHidden = true
return view
}()
private let failureTitleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Playback could not start"
label.textColor = .white
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
return label
}()
private let failureDetailLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = UIColor.white.withAlphaComponent(0.82)
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .subheadline)
label.adjustsFontForContentSizeCategory = true
return label
}()
private let retryButton: UIButton = NativePlayerViewController.textButton(title: "Retry")
private let failureCloseButton: UIButton = NativePlayerViewController.textButton(title: "Close")
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
@ -136,9 +192,14 @@ final class NativePlayerViewController: UIViewController {
view.backgroundColor = .black
configureBackend()
configureLayout()
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
configureAccessibility()
startPlayback()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
bottomScrimLayer.frame = view.bounds
updateLayoutForCurrentSize()
}
@discardableResult
@ -212,7 +273,7 @@ final class NativePlayerViewController: UIViewController {
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingView.isHidden = true
self?.loadingContainer.isHidden = true
self?.startProgressUpdates()
self?.refreshControls()
self?.scheduleControlsHide()
@ -241,6 +302,16 @@ final class NativePlayerViewController: UIViewController {
}
}
private func startPlayback() {
loadingContainer.isHidden = false
loadingView.startAnimating()
failureContainer.isHidden = true
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
revealControls()
}
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
@ -251,18 +322,30 @@ final class NativePlayerViewController: UIViewController {
private func configureLayout() {
view.addSubview(backend.view)
configureBottomScrim()
view.addSubview(tapSurfaceView)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(loadingContainer)
view.addSubview(failureContainer)
view.addSubview(centerPlayPauseButton)
view.addSubview(controlsContainer)
view.addSubview(scrubTimeBubble)
view.addSubview(closeButton)
loadingStack.addArrangedSubview(loadingView)
loadingStack.addArrangedSubview(loadingTextLabel)
loadingContainer.contentView.addSubview(loadingStack)
configureFailureCard()
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
centerPlayPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryPlayback), for: .touchUpInside)
failureCloseButton.addTarget(self, action: #selector(close), for: .touchUpInside)
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 24
playPauseButton.layer.cornerRadius = 28
centerPlayPauseButton.layer.cornerRadius = 34
centerPlayPauseButton.alpha = 0
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
@ -270,6 +353,14 @@ final class NativePlayerViewController: UIViewController {
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
let leftDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftDoubleTap))
leftDoubleTap.numberOfTapsRequired = 2
let rightDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleRightDoubleTap))
rightDoubleTap.numberOfTapsRequired = 2
tap.require(toFail: leftDoubleTap)
tap.require(toFail: rightDoubleTap)
tapSurfaceView.addGestureRecognizer(leftDoubleTap)
tapSurfaceView.addGestureRecognizer(rightDoubleTap)
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
@ -297,6 +388,8 @@ final class NativePlayerViewController: UIViewController {
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
controlsMaximumWidthConstraint = controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430)
NSLayoutConstraint.activate([
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
@ -308,21 +401,31 @@ final class NativePlayerViewController: UIViewController {
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loadingContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loadingStack.leadingAnchor.constraint(equalTo: loadingContainer.contentView.leadingAnchor, constant: 18),
loadingStack.trailingAnchor.constraint(equalTo: loadingContainer.contentView.trailingAnchor, constant: -18),
loadingStack.topAnchor.constraint(equalTo: loadingContainer.contentView.topAnchor, constant: 14),
loadingStack.bottomAnchor.constraint(equalTo: loadingContainer.contentView.bottomAnchor, constant: -14),
failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28),
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
failureContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
failureContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
failureContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -40),
failureContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 420),
closeButton.widthAnchor.constraint(equalToConstant: 36),
closeButton.heightAnchor.constraint(equalToConstant: 36),
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerPlayPauseButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
centerPlayPauseButton.widthAnchor.constraint(equalToConstant: 68),
centerPlayPauseButton.heightAnchor.constraint(equalToConstant: 68),
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
controlsMaximumWidthConstraint!,
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
@ -334,64 +437,108 @@ final class NativePlayerViewController: UIViewController {
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
audioButton.widthAnchor.constraint(equalToConstant: 36),
audioButton.heightAnchor.constraint(equalToConstant: 36),
scrubTimeBubble.centerXAnchor.constraint(equalTo: controlsContainer.centerXAnchor),
scrubTimeBubble.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8),
scrubTimeBubble.widthAnchor.constraint(greaterThanOrEqualToConstant: 64),
scrubTimeBubble.heightAnchor.constraint(equalToConstant: 28),
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
playPauseButton.widthAnchor.constraint(equalToConstant: 56),
playPauseButton.heightAnchor.constraint(equalToConstant: 56),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
audioButton.widthAnchor.constraint(equalToConstant: 44),
audioButton.heightAnchor.constraint(equalToConstant: 44),
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36)
captionsButton.widthAnchor.constraint(equalToConstant: 44),
captionsButton.heightAnchor.constraint(equalToConstant: 44)
])
}
private func showFailure(_ error: Error) {
controlsTimer?.invalidate()
loadingView.stopAnimating()
loadingView.isHidden = true
failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"
failureLabel.isHidden = false
loadingContainer.isHidden = true
controlsContainer.alpha = 0
controlsContainer.isUserInteractionEnabled = false
failureDetailLabel.text = error.localizedDescription
failureContainer.isHidden = false
closeButton.alpha = 1
closeButton.isUserInteractionEnabled = true
#if DEBUG
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
#endif
}
@objc private func retryPlayback() {
backend.stop()
progressTimer?.invalidate()
audioMenuSignature = nil
captionsMenuSignature = nil
startPlayback()
}
@objc private func close() {
dismiss(animated: true)
}
@objc private func togglePlayPause() {
backend.togglePlayPause()
flashCenterPlayPauseIcon()
revealControls()
}
@objc private func jumpBack() {
backend.jump(by: -15)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
revealControls()
}
@objc private func jumpForward() {
backend.jump(by: 15)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
revealControls()
}
@objc private func scrubbingStarted() {
isScrubbing = true
controlsTimer?.invalidate()
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 20), for: .normal)
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 22), for: .highlighted)
UISelectionFeedbackGenerator().selectionChanged()
updateScrubPreview()
UIView.animate(withDuration: 0.16) {
self.scrubTimeBubble.alpha = 1
}
}
@objc private func scrubberChanged() {
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
updateScrubPreview()
}
@objc private func scrubbingEnded() {
backend.seek(to: scrubber.value)
isScrubbing = false
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 14), for: .normal)
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 18), for: .highlighted)
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIView.animate(withDuration: 0.18) {
self.scrubTimeBubble.alpha = 0
}
revealControls()
}
@objc private func handleLeftDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.location(in: tapSurfaceView).x < tapSurfaceView.bounds.midX else { return }
jumpBack()
}
@objc private func handleRightDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.location(in: tapSurfaceView).x >= tapSurfaceView.bounds.midX else { return }
jumpForward()
}
@objc private func toggleControlsVisibility() {
if controlsContainer.alpha < 1 {
revealControls()
@ -428,7 +575,7 @@ final class NativePlayerViewController: UIViewController {
}
let delayActions = UIMenu(
title: "Delay",
title: "Subtitle Delay",
options: .displayInline,
children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in
@ -441,6 +588,12 @@ final class NativePlayerViewController: UIViewController {
self?.captionsMenuSignature = nil
self?.refreshControls()
},
UIAction(title: "Reset Delay") { [weak self] _ in
guard let self else { return }
self.backend.adjustSubtitleDelay(by: -self.backend.subtitleDelay)
self.captionsMenuSignature = nil
self.refreshControls()
},
UIAction(
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
attributes: .disabled
@ -448,7 +601,7 @@ final class NativePlayerViewController: UIViewController {
]
)
return UIMenu(title: "Captions", children: trackActions + [delayActions])
return UIMenu(title: "Subtitles", children: trackActions + [delayActions])
}
private func audioMenu() -> UIMenu {
@ -478,7 +631,7 @@ final class NativePlayerViewController: UIViewController {
}
}
return UIMenu(title: "Audio", children: trackActions)
return UIMenu(title: "Audio Track", children: trackActions)
}
private func startProgressUpdates() {
@ -499,6 +652,7 @@ final class NativePlayerViewController: UIViewController {
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
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
}
@ -514,6 +668,7 @@ final class NativePlayerViewController: UIViewController {
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
audioButton.isEnabled = hasSelectableTrack
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available."
guard signature != audioMenuSignature else {
return
}
@ -534,6 +689,8 @@ final class NativePlayerViewController: UIViewController {
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
captionsButton.isEnabled = hasSelectableTrack
captionsButton.alpha = hasSelectableTrack ? 1 : 0.45
captionsButton.accessibilityHint = hasSelectableTrack ? "Opens subtitle tracks and delay controls." : "No subtitle tracks are available yet."
guard signature != captionsMenuSignature else {
return
}
@ -567,17 +724,25 @@ final class NativePlayerViewController: UIViewController {
private func revealControls() {
controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true
UIView.animate(withDuration: 0.18) {
let animations = {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
self.controlsContainer.transform = .identity
}
if UIAccessibility.isReduceMotionEnabled {
animations()
} else {
controlsContainer.transform = CGAffineTransform(translationX: 0, y: 8).scaledBy(x: 0.98, y: 0.98)
UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut], animations: animations)
}
scheduleControlsHide()
}
private func hideControls() {
guard !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return }
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
UIView.animate(withDuration: 0.24) {
UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
}
@ -585,27 +750,143 @@ final class NativePlayerViewController: UIViewController {
private func scheduleControlsHide() {
controlsTimer?.invalidate()
guard backend.isPlaying else {
guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else {
return
}
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ in
self?.hideControls()
}
}
private static func iconButton(systemName: String, label: String) -> UIButton {
private func configureBottomScrim() {
bottomScrimLayer.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(0.12).cgColor,
UIColor.black.withAlphaComponent(0.48).cgColor
]
bottomScrimLayer.locations = [0, 0.58, 1]
view.layer.insertSublayer(bottomScrimLayer, above: backend.view.layer)
}
private func configureFailureCard() {
let buttonRow = UIStackView(arrangedSubviews: [failureCloseButton, retryButton])
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buttonRow.axis = .horizontal
buttonRow.distribution = .fillEqually
buttonRow.spacing = 10
let stack = UIStackView(arrangedSubviews: [failureTitleLabel, failureDetailLabel, buttonRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 14
failureContainer.contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: failureContainer.contentView.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: failureContainer.contentView.trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: failureContainer.contentView.topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: failureContainer.contentView.bottomAnchor, constant: -20),
retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
failureCloseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
])
}
private func configureAccessibility() {
scrubber.accessibilityLabel = "Playback Position"
scrubber.accessibilityHint = "Adjusts the playback position."
playPauseButton.accessibilityHint = "Toggles playback."
jumpBackButton.accessibilityHint = "Rewinds 15 seconds when seeking is available."
jumpForwardButton.accessibilityHint = "Skips ahead 15 seconds when seeking is available."
view.accessibilityCustomActions = [
UIAccessibilityCustomAction(name: "Jump Back 15 Seconds", target: self, selector: #selector(accessibilityJumpBack)),
UIAccessibilityCustomAction(name: "Jump Forward 15 Seconds", target: self, selector: #selector(accessibilityJumpForward))
]
}
@objc private func accessibilityJumpBack() -> Bool {
jumpBack()
return true
}
@objc private func accessibilityJumpForward() -> Bool {
jumpForward()
return true
}
private func updateLayoutForCurrentSize() {
controlsMaximumWidthConstraint?.constant = traitCollection.horizontalSizeClass == .regular ? 560 : 430
}
private func updateScrubPreview() {
let target = TimeInterval(scrubber.value) * backend.duration
let label = PlaybackTimeFormatter.label(for: target)
elapsedLabel.text = label
scrubTimeBubble.text = " \(label) "
}
private func flashCenterPlayPauseIcon() {
centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
guard !UIAccessibility.isReduceMotionEnabled else { return }
centerPlayPauseButton.transform = CGAffineTransform(scaleX: 0.86, y: 0.86)
UIView.animate(withDuration: 0.16, animations: {
self.centerPlayPauseButton.alpha = 1
self.centerPlayPauseButton.transform = .identity
}) { _ in
UIView.animate(withDuration: 0.22, delay: 0.28, options: [.curveEaseOut]) {
self.centerPlayPauseButton.alpha = 0
}
}
}
private static func glassPanel(cornerRadius: CGFloat) -> UIVisualEffectView {
let effect: UIVisualEffect
if #available(iOS 26.0, *) {
let glassEffect = UIGlassEffect()
glassEffect.tintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 0.16)
glassEffect.isInteractive = true
effect = glassEffect
} else {
effect = UIBlurEffect(style: .systemUltraThinMaterialDark)
}
let view = UIVisualEffectView(effect: effect)
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = cornerRadius
view.clipsToBounds = true
view.backgroundColor = UIColor.white.withAlphaComponent(0.09)
view.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
view.layer.borderWidth = 1
return view
}
private static func iconButton(systemName: String, label: String, pointSize: CGFloat = 20) -> UIButton {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: systemName), for: .normal)
let configuration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
button.setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
button.layer.cornerRadius = 18
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
button.layer.cornerRadius = 22
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = label
return button
}
private static func textButton(title: String) -> UIButton {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.backgroundColor = UIColor.white.withAlphaComponent(0.16)
button.layer.cornerRadius = 14
button.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
button.layer.borderWidth = 1
return button
}
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale