Streamline Native Player Controls
The native playback overlay was reduced from a large bottom control panel into a compact, centered control pill that leaves more of the video visible while preserving playback, seeking, jump, captions, and close actions.
Summary
Dreamio's native player controls now occupy much less vertical and horizontal space. The bottom controls use tighter padding, smaller circular buttons, smaller time labels, and a slimmer scrubber thumb so the screen feels more like a native iOS video player.
Changes Made
- Reworked the controls from a wide full-width panel into a centered compact overlay capped at 430 points.
- Combined elapsed time, scrubber, and remaining time into one horizontal row.
- Reduced button sizes while keeping circular targets for close, jump, play/pause, and captions.
- Reduced control padding and spacing to lower the overlay height.
- Added custom scrubber thumb images for a slimmer native-feeling slider.
Context
The previous player overlay was visually heavy and could feel like it took over the bottom half of the playback surface. The requested direction was to make it much more streamlined and closer to a native player experience.
Important Implementation Details
The behavior lives in Dreamio/NativePlayerViewController.swift. This change only adjusts the UIKit control layout and visual treatment. It does not change VLC playback, stream resolution, subtitle selection behavior, timers, or dismiss behavior.
The compact overlay keeps the scrubber usable by giving it a minimum width while allowing the container to shrink to its content and stay within the safe area.
Relevant Diff Snippets
The rendered diff below was generated with @pierre/diffs/ssr.
22 unmodified lines232425262728291 unmodified line3132333435363714 unmodified lines525354555657582 unmodified lines616263646566677 unmodified lines757677787980103 unmodified lines1841851861871881892 unmodified lines19219319419519619719819920020120220320420520620720820921021121221314 unmodified lines228229230231232233234235236237238239240241242243244245246247248249250251252253254255129 unmodified lines38538638738838939039139222 unmodified linesbutton.setImage(UIImage(systemName: "xmark"), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.black.withAlphaComponent(0.45)button.layer.cornerRadius = 22button.accessibilityLabel = "Close"return button}()1 unmodified lineprivate let controlsContainer: UIVisualEffectView = {let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))view.translatesAutoresizingMaskIntoConstraints = falseview.layer.cornerRadius = 12view.clipsToBounds = truereturn view}()14 unmodified lineslet label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)label.text = "0:00"return label}()2 unmodified lineslet label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)label.textAlignment = .rightlabel.text = "-0:00"return label7 unmodified linesslider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)slider.thumbTintColor = .whitereturn slider}()103 unmodified linesjumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)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])2 unmodified linestap.cancelsTouchesInView = falsetapSurfaceView.addGestureRecognizer(tap)let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])controlRow.translatesAutoresizingMaskIntoConstraints = falsecontrolRow.axis = .horizontalcontrolRow.alignment = .centercontrolRow.distribution = .equalCenteringcontrolRow.spacing = 18let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])timeRow.translatesAutoresizingMaskIntoConstraints = falsetimeRow.axis = .horizontaltimeRow.distribution = .fillEquallylet stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])stack.translatesAutoresizingMaskIntoConstraints = falsestack.axis = .verticalstack.spacing = 8controlsContainer.contentView.addSubview(stack)NSLayoutConstraint.activate([14 unmodified linesfailureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),closeButton.widthAnchor.constraint(equalToConstant: 44),closeButton.heightAnchor.constraint(equalToConstant: 44),closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),jumpBackButton.widthAnchor.constraint(equalToConstant: 44),jumpBackButton.heightAnchor.constraint(equalToConstant: 44),playPauseButton.widthAnchor.constraint(equalToConstant: 54),playPauseButton.heightAnchor.constraint(equalToConstant: 54),jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),captionsButton.widthAnchor.constraint(equalToConstant: 44),captionsButton.heightAnchor.constraint(equalToConstant: 44)])}129 unmodified linesbutton.setImage(UIImage(systemName: systemName), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.black.withAlphaComponent(0.35)button.layer.cornerRadius = 22button.accessibilityLabel = labelreturn button}}22 unmodified lines232425262728291 unmodified line3132333435363714 unmodified lines525354555657582 unmodified lines616263646566677 unmodified lines7576777879808182103 unmodified lines1861871881891901911922 unmodified lines19519619719819920020120220320420520620720820921021121221321421521621714 unmodified lines232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264129 unmodified lines39439539639739839940040140240340440540640740840941022 unmodified linesbutton.setImage(UIImage(systemName: "xmark"), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.black.withAlphaComponent(0.45)button.layer.cornerRadius = 18button.accessibilityLabel = "Close"return button}()1 unmodified lineprivate let controlsContainer: UIVisualEffectView = {let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))view.translatesAutoresizingMaskIntoConstraints = falseview.layer.cornerRadius = 16view.clipsToBounds = truereturn view}()14 unmodified lineslet label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)label.text = "0:00"return label}()2 unmodified lineslet label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)label.textAlignment = .rightlabel.text = "-0:00"return label7 unmodified linesslider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)slider.thumbTintColor = .whiteslider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal)slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted)return slider}()103 unmodified linesjumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)playPauseButton.layer.cornerRadius = 21scrubber.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])2 unmodified linestap.cancelsTouchesInView = falsetapSurfaceView.addGestureRecognizer(tap)let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = falsetimeAndScrubRow.axis = .horizontaltimeAndScrubRow.alignment = .centertimeAndScrubRow.spacing = 8let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])controlRow.translatesAutoresizingMaskIntoConstraints = falsecontrolRow.axis = .horizontalcontrolRow.alignment = .centercontrolRow.distribution = .equalSpacingcontrolRow.spacing = 14let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])stack.translatesAutoresizingMaskIntoConstraints = falsestack.axis = .verticalstack.spacing = 6controlsContainer.contentView.addSubview(stack)NSLayoutConstraint.activate([14 unmodified linesfailureLabel.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),stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12),stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8),stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10),elapsedLabel.widthAnchor.constraint(equalToConstant: 42),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),captionsButton.widthAnchor.constraint(equalToConstant: 36),captionsButton.heightAnchor.constraint(equalToConstant: 36)])}129 unmodified linesbutton.setImage(UIImage(systemName: systemName), for: .normal)button.tintColor = .whitebutton.backgroundColor = UIColor.black.withAlphaComponent(0.35)button.layer.cornerRadius = 18button.accessibilityLabel = labelreturn button}private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {let format = UIGraphicsImageRendererFormat()format.scale = UIScreen.main.scalereturn UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context inUIColor.white.setFill()context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter)))}}}
Expected Impact for End-Users
Users should see more video and less chrome when controls are visible. Playback controls remain familiar, but the overlay is quieter and less intrusive, especially on smaller phones or landscape playback.
Validation
Passed:
xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build
The build completed successfully. Xcode still reports the existing MobileVLCKit script-phase warning about missing outputs; this was not introduced by this player UI change.
Issues, Limitations, and Mitigations
- No simulator playback walkthrough was performed in this pass, so the exact visual feel should still be checked on device or simulator with real playback.
- The controls are intentionally smaller. Accessibility labels remain in place, but future work could add larger pointer or VoiceOver-specific affordances if needed.
Follow-up Work
No required follow-up Beads issues were created. A useful next polish pass would be a simulator/device visual check during active playback, especially in portrait and landscape.