import UIKit final class NativePlayerViewController: UIViewController { private let request: NativePlaybackRequest private var backend: NativePlaybackBackend private var startupTimer: Timer? private var controlsTimer: Timer? private var progressTimer: Timer? private var isScrubbing = false private var attachedSubtitleURLs: Set var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .large) view.translatesAutoresizingMaskIntoConstraints = false view.color = .white view.startAnimating() return view }() private let closeButton: UIButton = { let button = UIButton(type: .system) 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.accessibilityLabel = "Close" return button }() private let controlsContainer: UIVisualEffectView = { let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) view.translatesAutoresizingMaskIntoConstraints = false view.layer.cornerRadius = 16 view.clipsToBounds = true return view }() private let tapSurfaceView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .clear return 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 captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions") private let elapsedLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.text = "0:00" return label }() private let remainingLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.textAlignment = .right label.text = "-0:00" return label }() private let scrubber: UISlider = { let slider = UISlider() slider.translatesAutoresizingMaskIntoConstraints = false slider.minimumValue = 0 slider.maximumValue = 1 slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1) slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3) slider.thumbTintColor = .white slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal) slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted) return slider }() private let failureLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white label.textAlignment = .center label.numberOfLines = 0 label.font = .preferredFont(forTextStyle: .body) label.isHidden = true return label }() init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { self.request = request self.backend = backend self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url)) super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .crossDissolve } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .allButUpsideDown } override var prefersHomeIndicatorAutoHidden: Bool { true } override var prefersStatusBarHidden: Bool { true } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black configureBackend() configureLayout() startStartupTimer() backend.play(request: request) } @discardableResult func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int { let newCandidates = candidates.filter { candidate in guard !attachedSubtitleURLs.contains(candidate.url) else { return false } attachedSubtitleURLs.insert(candidate.url) return true } let attachedCount = backend.addSubtitleCandidates(newCandidates) if attachedCount > 0 { refreshControls() } #if DEBUG let duplicateCount = candidates.count - newCandidates.count print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)") #endif return attachedCount } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) startupTimer?.invalidate() controlsTimer?.invalidate() progressTimer?.invalidate() backend.stop() onDismiss?() } private func configureBackend() { backend.prepare(in: self) backend.view.translatesAutoresizingMaskIntoConstraints = false backend.onReady = { [weak self] in DispatchQueue.main.async { self?.startupTimer?.invalidate() self?.loadingView.stopAnimating() self?.loadingView.isHidden = true self?.startProgressUpdates() self?.refreshControls() self?.scheduleControlsHide() } } backend.onFailure = { [weak self] error in DispatchQueue.main.async { self?.startupTimer?.invalidate() self?.showFailure(error) } } backend.onStateChange = { [weak self] in DispatchQueue.main.async { self?.refreshControls() } } backend.onSubtitleTracksChange = { [weak self] in DispatchQueue.main.async { self?.refreshControls() } } } private func startStartupTimer() { startupTimer?.invalidate() startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in self?.backend.stop() self?.showFailure(NativePlaybackError.startupTimedOut) } } private 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) captionsButton.showsMenuAsPrimaryAction = true playPauseButton.layer.cornerRadius = 21 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]) let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) tap.cancelsTouchesInView = false tapSurfaceView.addGestureRecognizer(tap) let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel]) timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false timeAndScrubRow.axis = .horizontal timeAndScrubRow.alignment = .center timeAndScrubRow.spacing = 8 let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) controlRow.translatesAutoresizingMaskIntoConstraints = false controlRow.axis = .horizontal controlRow.alignment = .center controlRow.distribution = .equalSpacing controlRow.spacing = 14 let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow]) stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .vertical stack.spacing = 6 controlsContainer.contentView.addSubview(stack) NSLayoutConstraint.activate([ backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), backend.view.topAnchor.constraint(equalTo: view.topAnchor), backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), tapSurfaceView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tapSurfaceView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tapSurfaceView.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), 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) ]) } private func showFailure(_ error: Error) { loadingView.stopAnimating() loadingView.isHidden = true failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)" failureLabel.isHidden = false #if DEBUG print("[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 = true controlsTimer?.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 = false revealControls() } @objc private func toggleControlsVisibility() { if controlsContainer.alpha < 1 { revealControls() } else if backend.isPlaying { hideControls() } } private func captionsMenu() -> UIMenu { let selectedTrackID = backend.selectedSubtitleTrackID let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in UIAction( title: track.name, state: track.id == selectedTrackID ? .on : .off ) { [weak self] _ in self?.backend.selectSubtitleTrack(id: track.id) self?.refreshControls() } } let delayActions = UIMenu( title: "Delay", options: .displayInline, children: [ UIAction(title: "Decrease 0.5s") { [weak self] _ in self?.backend.adjustSubtitleDelay(by: -0.5) self?.refreshControls() }, UIAction(title: "Increase 0.5s") { [weak self] _ in self?.backend.adjustSubtitleDelay(by: 0.5) self?.refreshControls() }, UIAction( title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))", attributes: .disabled ) { _ in } ] ) return UIMenu(title: "Captions", children: trackActions + [delayActions]) } private func startProgressUpdates() { progressTimer?.invalidate() progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.refreshControls() } } private func refreshControls() { playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) scrubber.isEnabled = backend.isSeekable jumpBackButton.isEnabled = backend.isSeekable jumpForwardButton.isEnabled = backend.isSeekable captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty captionsButton.menu = captionsMenu() elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" if !isScrubbing { scrubber.value = backend.position } [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } } private func revealControls() { controlsContainer.isUserInteractionEnabled = true closeButton.isUserInteractionEnabled = true UIView.animate(withDuration: 0.18) { self.controlsContainer.alpha = 1 self.closeButton.alpha = 1 } scheduleControlsHide() } private func hideControls() { controlsContainer.isUserInteractionEnabled = false closeButton.isUserInteractionEnabled = false UIView.animate(withDuration: 0.24) { self.controlsContainer.alpha = 0 self.closeButton.alpha = 0 } } private func scheduleControlsHide() { controlsTimer?.invalidate() guard backend.isPlaying else { return } controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in self?.hideControls() } } private static func iconButton(systemName: String, label: String) -> UIButton { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setImage(UIImage(systemName: systemName), for: .normal) button.tintColor = .white button.backgroundColor = UIColor.black.withAlphaComponent(0.35) button.layer.cornerRadius = 18 button.accessibilityLabel = label return button } private static func scrubberThumbImage(diameter: CGFloat) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = UIScreen.main.scale return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in UIColor.white.setFill() context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter))) } } }