Dreamio turn document
Native Player Liquid Glass UX
The native VLC player received a fuller control experience: Liquid Glass-backed panels on iOS 26, larger controls, clearer loading and failure states, scrubbing feedback, double-tap seeking, menu polish, iPad width adaptation, and accessibility improvements.
Summary
Implemented the remaining player UX plan in Dreamio/NativePlayerViewController.swift, keeping the VLC backend contract intact while improving the control surface and user feedback around playback.
Changes Made
- Added an availability-gated
UIGlassEffectpanel helper with blur fallback for older iOS versions. - Restructured overlay elements into bottom controls, centered transient play/pause feedback, glass loading pill, and glass failure card.
- Raised touch targets to 44 points and made play/pause the dominant 56-point action.
- Added scrub target bubble, larger active thumb, haptics, and double-tap left/right seek gestures.
- Extended auto-hide timing, added a bottom contrast scrim, improved audio/subtitle menu labels, and added reset subtitle delay.
- Added retry/close failure actions, iPad wider controls, reduced-motion-aware animation, accessibility hints, scrubber value, and custom accessibility jump actions.
Context
The prior baseline was compact and functional but used a plain blur tray, small controls, a spinner-only loading state, and a text-only failure state. This pass keeps the same native playback architecture while making the player feel more intentional and touch-friendly.
Important Implementation Details
- Liquid Glass is gated behind
#available(iOS 26.0, *); older systems keepsystemUltraThinMaterialDark. - No backend API changes were required; scrub previews are timestamp-only because the backend does not expose preview frames.
- Auto-hide now avoids hiding during scrubbing, loading, or failure states and uses 4.5 seconds for more comfortable interaction.
- Retry restarts the same native playback request and resets cached menu signatures.
Relevant Diff Snippets
Dreamio/NativePlayerViewController.swift ยท player controls UX pass
10 unmodified lines11121314151617187 unmodified lines262728293031323334353637383940414243444546472 unmodified lines505152535455565758596027 unmodified lines88899091929394959697989910010110210310431 unmodified lines13613713813914014114214314467 unmodified lines21221321421521621721822 unmodified lines2412422432442452464 unmodified lines2512522532542552562572582592602612622632642652662672681 unmodified line27027127227327427521 unmodified lines2972982993003013025 unmodified lines3083093103113123133143153163173183193203213223233243253263273285 unmodified lines33433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639730 unmodified lines4284294304314324334346 unmodified lines4414424434444454461 unmodified line44844945045145245345423 unmodified lines47847948048148248348414 unmodified lines4995005015025035049 unmodified lines51451551651751851914 unmodified lines53453553653753853927 unmodified lines5675685695705715725735745755765775785795805815825831 unmodified line58558658758858959059159259359459559659759859960060160260360460560660760860961061110 unmodified linesprivate var attachedSubtitleURLs: Set<URL>private var audioMenuSignature: String?private var captionsMenuSignature: String?var onDismiss: (() -> Void)?private let loadingView: UIActivityIndicatorView = {let view = UIActivityIndicatorView(style: .large)view.translatesAutoresizingMaskIntoConstraints = false7 unmodified linesbutton.translatesAutoresizingMaskIntoConstraints = falsebutton.setImage(UIImage(systemName: "xmark"), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.black.withAlphaComponent(0.45)button.layer.cornerRadius = 18button.accessibilityLabel = "Close"return button}()private let controlsContainer: UIVisualEffectView = {let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))view.translatesAutoresizingMaskIntoConstraints = falseview.layer.cornerRadius = 22view.clipsToBounds = trueview.backgroundColor = UIColor.white.withAlphaComponent(0.08)view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColorview.layer.borderWidth = 1return view}()private let tapSurfaceView: UIView = {let view = UIView()2 unmodified linesreturn view}()private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")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 elapsedLabel: UILabel = {let label = UILabel()27 unmodified linesreturn slider}()private let failureLabel: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.textAlignment = .centerlabel.numberOfLines = 0label.font = .preferredFont(forTextStyle: .body)label.isHidden = truereturn label}()init(request: NativePlaybackRequest,backend: NativePlaybackBackend = VLCNativePlaybackBackend(),31 unmodified linesview.backgroundColor = .blackconfigureBackend()configureLayout()startStartupTimer()backend.play(request: request)addSubtitleCandidates(request.subtitleCandidates)}@discardableResult67 unmodified linesDispatchQueue.main.async {self?.startupTimer?.invalidate()self?.loadingView.stopAnimating()self?.loadingView.isHidden = trueself?.startProgressUpdates()self?.refreshControls()self?.scheduleControlsHide()22 unmodified lines}}private func startStartupTimer() {startupTimer?.invalidate()startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in4 unmodified linesprivate func configureLayout() {view.addSubview(backend.view)view.addSubview(tapSurfaceView)view.addSubview(loadingView)view.addSubview(failureLabel)view.addSubview(controlsContainer)view.addSubview(closeButton)closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)audioButton.showsMenuAsPrimaryAction = truecaptionsButton.showsMenuAsPrimaryAction = trueplayPauseButton.layer.cornerRadius = 24scrubber.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])1 unmodified linelet tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))tap.cancelsTouchesInView = falsetapSurfaceView.addGestureRecognizer(tap)let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false21 unmodified linesstack.spacing = 6controlsContainer.contentView.addSubview(stack)NSLayoutConstraint.activate([backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),5 unmodified linestapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28),failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),closeButton.widthAnchor.constraint(equalToConstant: 36),closeButton.heightAnchor.constraint(equalToConstant: 36),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),controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),5 unmodified linesremainingLabel.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),playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),captionsButton.widthAnchor.constraint(equalToConstant: 36),captionsButton.heightAnchor.constraint(equalToConstant: 36)])}private func showFailure(_ error: Error) {loadingView.stopAnimating()loadingView.isHidden = truefailureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"failureLabel.isHidden = false#if DEBUGprint("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")#endif}@objc private func close() {dismiss(animated: true)}@objc private func togglePlayPause() {backend.togglePlayPause()revealControls()}@objc private func jumpBack() {backend.jump(by: -15)revealControls()}@objc private func jumpForward() {backend.jump(by: 15)revealControls()}@objc private func scrubbingStarted() {isScrubbing = truecontrolsTimer?.invalidate()}@objc private func scrubberChanged() {elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)}@objc private func scrubbingEnded() {backend.seek(to: scrubber.value)isScrubbing = falserevealControls()}@objc private func toggleControlsVisibility() {if controlsContainer.alpha < 1 {revealControls()30 unmodified lines}let delayActions = UIMenu(title: "Delay",options: .displayInline,children: [UIAction(title: "Decrease 0.5s") { [weak self] _ in6 unmodified linesself?.captionsMenuSignature = nilself?.refreshControls()},UIAction(title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",attributes: .disabled1 unmodified line])return UIMenu(title: "Captions", children: trackActions + [delayActions])}private func audioMenu() -> UIMenu {23 unmodified lines}}return UIMenu(title: "Audio", children: trackActions)}private func startProgressUpdates() {14 unmodified linesupdateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {scrubber.value = backend.position}9 unmodified lineslet hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1audioButton.isEnabled = hasSelectableTrackaudioButton.alpha = hasSelectableTrack ? 1 : 0.45guard signature != audioMenuSignature else {return}14 unmodified lines)let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }captionsButton.isEnabled = hasSelectableTrackguard signature != captionsMenuSignature else {return}27 unmodified linesprivate func revealControls() {controlsContainer.isUserInteractionEnabled = truecloseButton.isUserInteractionEnabled = trueUIView.animate(withDuration: 0.18) {self.controlsContainer.alpha = 1self.closeButton.alpha = 1}scheduleControlsHide()}private func hideControls() {controlsContainer.isUserInteractionEnabled = falsecloseButton.isUserInteractionEnabled = falseUIView.animate(withDuration: 0.24) {self.controlsContainer.alpha = 0self.closeButton.alpha = 0}1 unmodified lineprivate func scheduleControlsHide() {controlsTimer?.invalidate()guard backend.isPlaying else {return}controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ inself?.hideControls()}}private static func iconButton(systemName: String, label: String) -> UIButton {let button = UIButton(type: .system)button.translatesAutoresizingMaskIntoConstraints = falsebutton.setImage(UIImage(systemName: systemName), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.white.withAlphaComponent(0.12)button.layer.cornerRadius = 18button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColorbutton.layer.borderWidth = 1button.accessibilityLabel = labelreturn button}private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {let format = UIGraphicsImageRendererFormat()format.scale = UIScreen.main.scale10 unmodified lines11121314151617181920212223242526272829303132333435363738394041424344457 unmodified lines535455565758596061626364656667682 unmodified lines7172737475767778798081828327 unmodified lines11111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916031 unmodified lines19219319419519619719819920020120220320420567 unmodified lines27327427527627727827922 unmodified lines3023033043053063073083093103113123133143153163174 unmodified lines3223233243253263273283293303313323333343353363373383393403413423433443453463473483493503511 unmodified line35335435535635735835936036136236336436536621 unmodified lines3883893903913923933943955 unmodified lines4014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304315 unmodified lines43743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354430 unmodified lines5755765775785795805816 unmodified lines5885895905915925935945955965975985991 unmodified line60160260360460560660723 unmodified lines63163263363463563663714 unmodified lines6526536546556566576589 unmodified lines66866967067167267367414 unmodified lines68969069169269369469569627 unmodified lines7247257267277287297307317327337347357367377387397407417427437447457467477481 unmodified line75075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189210 unmodified linesprivate 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 = falsereturn view}()private let loadingStack: UIStackView = {let stack = UIStackView()stack.translatesAutoresizingMaskIntoConstraints = falsestack.axis = .horizontalstack.alignment = .centerstack.spacing = 12return stack}()private let loadingTextLabel: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.text = "Opening streamโฆ"label.textColor = .whitelabel.font = .preferredFont(forTextStyle: .subheadline)label.adjustsFontForContentSizeCategory = truereturn label}()private let loadingView: UIActivityIndicatorView = {let view = UIActivityIndicatorView(style: .large)view.translatesAutoresizingMaskIntoConstraints = false7 unmodified linesbutton.translatesAutoresizingMaskIntoConstraints = falsebutton.setImage(UIImage(systemName: "xmark"), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.white.withAlphaComponent(0.14)button.layer.cornerRadius = 22button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColorbutton.layer.borderWidth = 1button.accessibilityLabel = "Close"button.accessibilityHint = "Closes native playback and returns to Stremio."return button}()private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26)private let tapSurfaceView: UIView = {let view = UIView()2 unmodified linesreturn view}()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 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()27 unmodified linesreturn slider}()private let scrubTimeBubble: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.textAlignment = .centerlabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold)label.backgroundColor = UIColor.black.withAlphaComponent(0.56)label.layer.cornerRadius = 14label.clipsToBounds = truelabel.alpha = 0return label}()private let failureContainer: UIVisualEffectView = {let view = NativePlayerViewController.glassPanel(cornerRadius: 28)view.isHidden = truereturn view}()private let failureTitleLabel: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.text = "Playback could not start"label.textColor = .whitelabel.textAlignment = .centerlabel.font = .preferredFont(forTextStyle: .headline)label.adjustsFontForContentSizeCategory = truereturn label}()private let failureDetailLabel: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = UIColor.white.withAlphaComponent(0.82)label.textAlignment = .centerlabel.numberOfLines = 0label.font = .preferredFont(forTextStyle: .subheadline)label.adjustsFontForContentSizeCategory = truereturn label}()private let retryButton: UIButton = NativePlayerViewController.textButton(title: "Retry")private let failureCloseButton: UIButton = NativePlayerViewController.textButton(title: "Close")init(request: NativePlaybackRequest,backend: NativePlaybackBackend = VLCNativePlaybackBackend(),31 unmodified linesview.backgroundColor = .blackconfigureBackend()configureLayout()configureAccessibility()startPlayback()}override func viewDidLayoutSubviews() {super.viewDidLayoutSubviews()bottomScrimLayer.frame = view.boundsupdateLayoutForCurrentSize()}@discardableResult67 unmodified linesDispatchQueue.main.async {self?.startupTimer?.invalidate()self?.loadingView.stopAnimating()self?.loadingContainer.isHidden = trueself?.startProgressUpdates()self?.refreshControls()self?.scheduleControlsHide()22 unmodified lines}}private func startPlayback() {loadingContainer.isHidden = falseloadingView.startAnimating()failureContainer.isHidden = truestartStartupTimer()backend.play(request: request)addSubtitleCandidates(request.subtitleCandidates)revealControls()}private func startStartupTimer() {startupTimer?.invalidate()startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in4 unmodified linesprivate func configureLayout() {view.addSubview(backend.view)configureBottomScrim()view.addSubview(tapSurfaceView)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 = truecaptionsButton.showsMenuAsPrimaryAction = trueplayPauseButton.layer.cornerRadius = 28centerPlayPauseButton.layer.cornerRadius = 34centerPlayPauseButton.alpha = 0scrubber.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])1 unmodified linelet tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))tap.cancelsTouchesInView = falsetapSurfaceView.addGestureRecognizer(tap)let leftDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftDoubleTap))leftDoubleTap.numberOfTapsRequired = 2let rightDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleRightDoubleTap))rightDoubleTap.numberOfTapsRequired = 2tap.require(toFail: leftDoubleTap)tap.require(toFail: rightDoubleTap)tapSurfaceView.addGestureRecognizer(leftDoubleTap)tapSurfaceView.addGestureRecognizer(rightDoubleTap)let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false21 unmodified linesstack.spacing = 6controlsContainer.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),5 unmodified linestapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),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),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),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),controlsMaximumWidthConstraint!,controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),5 unmodified linesremainingLabel.widthAnchor.constraint(equalToConstant: 42),scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),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: 44),captionsButton.heightAnchor.constraint(equalToConstant: 44)])}private func showFailure(_ error: Error) {controlsTimer?.invalidate()loadingView.stopAnimating()loadingContainer.isHidden = truecontrolsContainer.alpha = 0controlsContainer.isUserInteractionEnabled = falsefailureDetailLabel.text = error.localizedDescriptionfailureContainer.isHidden = falsecloseButton.alpha = 1closeButton.isUserInteractionEnabled = true#if DEBUGprint("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")#endif}@objc private func retryPlayback() {backend.stop()progressTimer?.invalidate()audioMenuSignature = nilcaptionsMenuSignature = nilstartPlayback()}@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 = truecontrolsTimer?.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() {updateScrubPreview()}@objc private func scrubbingEnded() {backend.seek(to: scrubber.value)isScrubbing = falsescrubber.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()30 unmodified lines}let delayActions = UIMenu(title: "Subtitle Delay",options: .displayInline,children: [UIAction(title: "Decrease 0.5s") { [weak self] _ in6 unmodified linesself?.captionsMenuSignature = nilself?.refreshControls()},UIAction(title: "Reset Delay") { [weak self] _ inguard let self else { return }self.backend.adjustSubtitleDelay(by: -self.backend.subtitleDelay)self.captionsMenuSignature = nilself.refreshControls()},UIAction(title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",attributes: .disabled1 unmodified line])return UIMenu(title: "Subtitles", children: trackActions + [delayActions])}private func audioMenu() -> UIMenu {23 unmodified lines}}return UIMenu(title: "Audio Track", children: trackActions)}private func startProgressUpdates() {14 unmodified linesupdateCaptionsMenuIfNeeded(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}9 unmodified lineslet hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1audioButton.isEnabled = hasSelectableTrackaudioButton.alpha = hasSelectableTrack ? 1 : 0.45audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available."guard signature != audioMenuSignature else {return}14 unmodified lines)let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }captionsButton.isEnabled = hasSelectableTrackcaptionsButton.alpha = hasSelectableTrack ? 1 : 0.45captionsButton.accessibilityHint = hasSelectableTrack ? "Opens subtitle tracks and delay controls." : "No subtitle tracks are available yet."guard signature != captionsMenuSignature else {return}27 unmodified linesprivate func revealControls() {controlsContainer.isUserInteractionEnabled = truecloseButton.isUserInteractionEnabled = truelet animations = {self.controlsContainer.alpha = 1self.closeButton.alpha = 1self.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 = falsecloseButton.isUserInteractionEnabled = falseUIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) {self.controlsContainer.alpha = 0self.closeButton.alpha = 0}1 unmodified lineprivate func scheduleControlsHide() {controlsTimer?.invalidate()guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else {return}controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ inself?.hideControls()}}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 = falsebuttonRow.axis = .horizontalbuttonRow.distribution = .fillEquallybuttonRow.spacing = 10let stack = UIStackView(arrangedSubviews: [failureTitleLabel, failureDetailLabel, buttonRow])stack.translatesAutoresizingMaskIntoConstraints = falsestack.axis = .verticalstack.alignment = .fillstack.spacing = 14failureContainer.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.durationlet label = PlaybackTimeFormatter.label(for: target)elapsedLabel.text = labelscrubTimeBubble.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 = 1self.centerPlayPauseButton.transform = .identity}) { _ inUIView.animate(withDuration: 0.22, delay: 0.28, options: [.curveEaseOut]) {self.centerPlayPauseButton.alpha = 0}}}private static func glassPanel(cornerRadius: CGFloat) -> UIVisualEffectView {let effect: UIVisualEffectif #available(iOS 26.0, *) {let glassEffect = UIGlassEffect()glassEffect.tintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 0.16)glassEffect.isInteractive = trueeffect = glassEffect} else {effect = UIBlurEffect(style: .systemUltraThinMaterialDark)}let view = UIVisualEffectView(effect: effect)view.translatesAutoresizingMaskIntoConstraints = falseview.layer.cornerRadius = cornerRadiusview.clipsToBounds = trueview.backgroundColor = UIColor.white.withAlphaComponent(0.09)view.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColorview.layer.borderWidth = 1return view}private static func iconButton(systemName: String, label: String, pointSize: CGFloat = 20) -> UIButton {let button = UIButton(type: .system)button.translatesAutoresizingMaskIntoConstraints = falselet configuration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)button.setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.white.withAlphaComponent(0.14)button.layer.cornerRadius = 22button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColorbutton.layer.borderWidth = 1button.accessibilityLabel = labelreturn button}private static func textButton(title: String) -> UIButton {let button = UIButton(type: .system)button.translatesAutoresizingMaskIntoConstraints = falsebutton.setTitle(title, for: .normal)button.setTitleColor(.white, for: .normal)button.titleLabel?.font = .preferredFont(forTextStyle: .headline)button.titleLabel?.adjustsFontForContentSizeCategory = truebutton.backgroundColor = UIColor.white.withAlphaComponent(0.16)button.layer.cornerRadius = 14button.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColorbutton.layer.borderWidth = 1return button}private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {let format = UIGraphicsImageRendererFormat()format.scale = UIScreen.main.scale
Rendered with @pierre/diffs/ssr.
Expected Impact for End-Users
Users should see a more polished, readable, and forgiving native player with better touch ergonomics, clearer recovery from failures, more helpful loading feedback, and faster jump interactions.
Validation
- Ran
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build. - Build succeeded.
Issues, Limitations, and Mitigations
- Manual real-device iOS 26 Liquid Glass visual validation is still recommended because simulator build validation cannot confirm material feel over live video.
- Scrubbing previews are timestamp-only until the backend can provide frame thumbnails.
Follow-up Work
- Manual QA on iPhone/iPad portrait and landscape with seekable/non-seekable streams, audio tracks, and captions.
- Consider future backend support for thumbnail previews during scrubbing.