// // Swiftfin is subject to the terms of the Mozilla Public // License, v2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at https://mozilla.org/MPL/2.0/. // // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // import AVFoundation import AVKit import Combine import Defaults import JellyfinAPI import MediaPlayer import MobileVLCKit import SwiftUI import UIKit // TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { // MARK: variables private var viewModel: VideoPlayerViewModel private var vlcMediaPlayer: VLCMediaPlayer private var lastPlayerTicks: Int64 = 0 private var lastProgressReportTicks: Int64 = 0 private var viewModelListeners = Set() private var overlayDismissTimer: Timer? private var isScreenFilled: Bool = false private var isGesturesLocked = false private var pinchScale: CGFloat = 1 private var currentPlayerTicks: Int64 { Int64(vlcMediaPlayer.time.intValue) * 100_000 } private var displayingOverlay: Bool { currentOverlayHostingController?.view.alpha ?? 0 > 0 } private var displayingChapterOverlay: Bool { currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 } private var panBeganBrightness = CGFloat.zero private var panBeganVolumeValue = Float.zero private var panBeganSliderPercentage: Double = 0 private var panBeganPoint = CGPoint.zero private var tapLocationStack = [CGPoint]() private var isJumping = false private var jumpingCompletionWork: DispatchWorkItem? private var isTapWhenJumping = false private lazy var videoContentView = makeVideoContentView() private lazy var mainGestureView = makeMainGestureView() private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() private lazy var lockedOverlayView = makeGestureLockedOverlayView() private var currentOverlayHostingController: UIHostingController? private var currentChapterOverlayHostingController: UIHostingController? private var currentJumpBackwardOverlayView: UIImageView? private var currentJumpForwardOverlayView: UIImageView? private var volumeView = MPVolumeView() override var keyCommands: [UIKeyCommand]? { var commands = [ UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), UIKeyCommand( title: L10n.nextItem, action: #selector(didSelectPlayNextItem), input: UIKeyCommand.inputRightArrow, modifierFlags: .command ), UIKeyCommand( title: L10n.previousItem, action: #selector(didSelectPlayPreviousItem), input: UIKeyCommand.inputLeftArrow, modifierFlags: .command ), UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), ] if let previous = viewModel.playbackSpeed.previous { commands.append(.init( title: "\(L10n.playbackSpeed) \(previous.displayTitle)", action: #selector(didSelectPreviousPlaybackSpeed), input: "[", modifierFlags: .command )) } if let next = viewModel.playbackSpeed.next { commands.append(.init( title: "\(L10n.playbackSpeed) \(next.displayTitle)", action: #selector(didSelectNextPlaybackSpeed), input: "]", modifierFlags: .command )) } if viewModel.playbackSpeed != .one { commands.append(.init( title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", action: #selector(didSelectNormalPlaybackSpeed), input: "\\", modifierFlags: .command )) } commands.forEach { $0.wantsPriorityOverSystemBehavior = true } return commands } // MARK: init init(viewModel: VideoPlayerViewModel) { self.viewModel = viewModel self.vlcMediaPlayer = VLCMediaPlayer() super.init(nibName: nil, bundle: nil) viewModel.playerOverlayDelegate = self } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupSubviews() { view.addSubview(videoContentView) view.addSubview(mainGestureView) view.addSubview(systemControlOverlayLabel) view.addSubview(lockedOverlayView) } private func setupConstraints() { NSLayoutConstraint.activate([ videoContentView.topAnchor.constraint(equalTo: view.topAnchor), videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), ]) NSLayoutConstraint.activate([ mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), ]) NSLayoutConstraint.activate([ systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) NSLayoutConstraint.activate([ lockedOverlayView.topAnchor.constraint(equalTo: view.topAnchor), lockedOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), lockedOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor), lockedOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor), ]) } // MARK: viewWillDisappear override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) NotificationCenter.default.removeObserver(self) } // MARK: viewDidLoad override func viewDidLoad() { super.viewDidLoad() setupSubviews() setupConstraints() view.backgroundColor = .black view.accessibilityIgnoresInvertColors = true setupMediaPlayer(newViewModel: viewModel) refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver( self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil ) defaultNotificationCenter.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil ) defaultNotificationCenter.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil ) } @objc private func appWillTerminate() { viewModel.sendStopReport() } @objc private func appWillResignActive() { hideChaptersOverlay() showOverlay() stopOverlayDismissTimer() vlcMediaPlayer.pause() viewModel.sendPauseReport(paused: true) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startPlayback() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { if isScreenFilled { fillScreen(screenSize: size) } super.viewWillTransition(to: size, with: coordinator) } // MARK: VideoContentView private func makeVideoContentView() -> UIView { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .black return view } // MARK: MainGestureView private func makeMainGestureView() -> UIView { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) let verticalGesture = PanDirectionGestureRecognizer(direction: .vertical, target: self, action: #selector(didVerticalPan(_:))) let horizontalGesture = PanDirectionGestureRecognizer(direction: .horizontal, target: self, action: #selector(didHorizontalPan(_:))) let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) view.addGestureRecognizer(singleTapGesture) view.addGestureRecognizer(pinchGesture) if viewModel.playerGesturesLockGestureEnabled { view.addGestureRecognizer(longPressGesture) } if viewModel.systemControlGesturesEnabled { view.addGestureRecognizer(verticalGesture) } if viewModel.seekSlideGestureEnabled { view.addGestureRecognizer(horizontalGesture) } return view } // MARK: SystemControlOverlayLabel private func makeSystemControlOverlayLabel() -> UILabel { let label = UILabel() label.alpha = 0 label.translatesAutoresizingMaskIntoConstraints = false label.font = .systemFont(ofSize: 48) label.layer.zPosition = 1 return label } // MARK: GestureLockedOverlayView private func makeGestureLockedOverlayView() -> UIView { let backgroundView = UIView() backgroundView.layer.zPosition = 1 backgroundView.alpha = 0 backgroundView.translatesAutoresizingMaskIntoConstraints = false let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in self?.isGesturesLocked = false self?.hideLockedOverlay() self?.didGenerallyTap() })) button.translatesAutoresizingMaskIntoConstraints = false button.setImage( UIImage(systemName: "lock.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? .withTintColor(.white), for: .normal ) backgroundView.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), button.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), ]) let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) backgroundView.addGestureRecognizer(singleTapGesture) return backgroundView } @objc private func didTap(_ gestureRecognizer: UITapGestureRecognizer) { didGenerallyTap(point: gestureRecognizer.location(in: mainGestureView)) } @objc func didLongPress() { guard !isGesturesLocked else { return } isGesturesLocked = true didGenerallyTap() } @objc private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { guard !isGesturesLocked else { return } if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { pinchScale = gestureRecognizer.scale } else { if pinchScale > 1, !isScreenFilled { isScreenFilled.toggle() fillScreen() } else if pinchScale < 1, isScreenFilled { isScreenFilled.toggle() shrinkScreen() } } } @objc private func didVerticalPan(_ gestureRecognizer: UIPanGestureRecognizer) { guard !isGesturesLocked else { return } switch gestureRecognizer.state { case .began: panBeganBrightness = UIScreen.main.brightness if let view = volumeView.subviews.first as? UISlider { panBeganVolumeValue = view.value } panBeganPoint = gestureRecognizer.location(in: mainGestureView) case .changed: let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 let pos = gestureRecognizer.location(in: mainGestureView) let moveDelta = pos.y - panBeganPoint.y let changedValue = moveDelta / mainGestureViewHalfHeight if panBeganPoint.x < mainGestureViewHalfWidth { UIScreen.main.brightness = panBeganBrightness - changedValue showBrightnessOverlay() } else if let view = volumeView.subviews.first as? UISlider { view.value = panBeganVolumeValue - Float(changedValue) showVolumeOverlay() } default: hideSystemControlOverlay() } } @objc private func didHorizontalPan(_ gestureRecognizer: UIPanGestureRecognizer) { guard !isGesturesLocked else { return } switch gestureRecognizer.state { case .began: exchangeOverlayView(isBringToFrontThanGestureView: false) panBeganPoint = gestureRecognizer.location(in: mainGestureView) panBeganSliderPercentage = viewModel.sliderPercentage viewModel.sliderIsScrubbing = true case .changed: let pos = gestureRecognizer.location(in: mainGestureView) let moveDelta = panBeganPoint.x - pos.x let changedValue = (moveDelta / mainGestureView.frame.width) viewModel.sliderPercentage = min(max(0, panBeganSliderPercentage - changedValue), 1) showSliderOverlay() showOverlay() default: viewModel.sliderIsScrubbing = false hideOverlay() hideSystemControlOverlay() } } // MARK: setupOverlayHostingController private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { // TODO: Look at injecting viewModel into the environment so it updates the current overlay if let currentOverlayHostingController = currentOverlayHostingController { // UX fade-out UIView.animate(withDuration: 0.5) { currentOverlayHostingController.view.alpha = 0 } completion: { _ in currentOverlayHostingController.view.isHidden = true currentOverlayHostingController.view.removeFromSuperview() currentOverlayHostingController.removeFromParent() } } let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) let newOverlayHostingController = UIHostingController(rootView: newOverlayView) newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false newOverlayHostingController.view.backgroundColor = UIColor.clear // UX fade-in newOverlayHostingController.view.alpha = 0 addChild(newOverlayHostingController) view.addSubview(newOverlayHostingController.view) newOverlayHostingController.didMove(toParent: self) NSLayoutConstraint.activate([ newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), ]) // UX fade-in UIView.animate(withDuration: 0.5) { newOverlayHostingController.view.alpha = 1 } currentOverlayHostingController = newOverlayHostingController if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { UIView.animate(withDuration: 0.5) { currentChapterOverlayHostingController.view.alpha = 0 } completion: { _ in currentChapterOverlayHostingController.view.isHidden = true currentChapterOverlayHostingController.view.removeFromSuperview() currentChapterOverlayHostingController.removeFromParent() } } let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false newChapterOverlayHostingController.view.backgroundColor = UIColor.clear newChapterOverlayHostingController.view.alpha = 0 addChild(newChapterOverlayHostingController) view.addSubview(newChapterOverlayHostingController.view) newChapterOverlayHostingController.didMove(toParent: self) NSLayoutConstraint.activate([ newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), ]) currentChapterOverlayHostingController = newChapterOverlayHostingController // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it navigationController?.isNavigationBarHidden = true } private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { currentJumpBackwardOverlayView.removeFromSuperview() } let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false newJumpBackwardImageView.tintColor = .white newJumpBackwardImageView.alpha = 0 view.addSubview(newJumpBackwardImageView) NSLayoutConstraint.activate([ newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) currentJumpBackwardOverlayView = newJumpBackwardImageView } private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { currentJumpForwardOverlayView.removeFromSuperview() } let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false newJumpForwardImageView.tintColor = .white newJumpForwardImageView.alpha = 0 view.addSubview(newJumpForwardImageView) NSLayoutConstraint.activate([ newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) currentJumpForwardOverlayView = newJumpForwardImageView } private var isOverlayViewBringToFrontThanGestureView = true private func exchangeOverlayView(isBringToFrontThanGestureView: Bool) { guard isBringToFrontThanGestureView != isOverlayViewBringToFrontThanGestureView, let currentOverlayView = currentOverlayHostingController?.view, let mainGestureViewIndex = view.subviews.firstIndex(of: mainGestureView), let currentOVerlayViewIndex = view.subviews.firstIndex(of: currentOverlayView) else { return } isOverlayViewBringToFrontThanGestureView = isBringToFrontThanGestureView view.exchangeSubview( at: mainGestureViewIndex, withSubviewAt: currentOVerlayViewIndex ) } } // MARK: setupMediaPlayer extension VLCPlayerViewController { /// Main function that handles setting up the media player with the current VideoPlayerViewModel /// and also takes the role of setting the 'viewModel' property with the given viewModel /// /// Use case for this is setting new media within the same VLCPlayerViewController func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { // remove old player if vlcMediaPlayer.media != nil { viewModelListeners.forEach { $0.cancel() } vlcMediaPlayer.stop() viewModel.sendStopReport() viewModel.playerOverlayDelegate = nil } vlcMediaPlayer = VLCMediaPlayer() // setup with new player and view model vlcMediaPlayer = VLCMediaPlayer() vlcMediaPlayer.delegate = self vlcMediaPlayer.drawable = videoContentView vlcMediaPlayer.setSubtitleFont(fontName: Defaults[.subtitleFontName]) vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) stopOverlayDismissTimer() lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 let media: VLCMedia if let transcodedURL = newViewModel.transcodedStreamURL, !Defaults[.Experimental.forceDirectPlay] { media = VLCMedia(url: transcodedURL) } else { media = VLCMedia(url: newViewModel.directStreamURL) } media.addOption("--prefetch-buffer-size=1048576") media.addOption("--network-caching=5000") vlcMediaPlayer.media = media setupOverlayHostingController(viewModel: newViewModel) setupViewModelListeners(viewModel: newViewModel) newViewModel.getAdjacentEpisodes() newViewModel.playerOverlayDelegate = self let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 if startPercentage > 0 { if viewModel.resumeOffset { let runTimeTicks = viewModel.item.runTimeTicks ?? 0 let videoDurationSeconds = Double(runTimeTicks / 10_000_000) var startSeconds = round((startPercentage / 100) * videoDurationSeconds) startSeconds = startSeconds.subtract(5, floor: 0) let newStartPercentage = startSeconds / videoDurationSeconds newViewModel.sliderPercentage = newStartPercentage } else { newViewModel.sliderPercentage = startPercentage / 100 } } viewModel = newViewModel if viewModel.streamType == .direct { LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? .emptyDash)") } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? .emptyDash)") } else { LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? .emptyDash)") } } // MARK: startPlayback func startPlayback() { vlcMediaPlayer.play() // Setup external subtitles for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) } } setMediaPlayerTimeAtCurrentSlider() viewModel.sendPlayReport() restartOverlayDismissTimer() } // MARK: setupViewModelListeners private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { viewModel.$playbackSpeed.sink { newSpeed in self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) }.store(in: &viewModelListeners) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { self.didBeginScrubbing() } else { self.didEndScrubbing() } }.store(in: &viewModelListeners) viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in self.didSelectAudioStream(index: newAudioStreamIndex) }.store(in: &viewModelListeners) viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in self.didSelectSubtitleStream(index: newSubtitleStreamIndex) }.store(in: &viewModelListeners) viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in self.didToggleSubtitles(newValue: newSubtitlesEnabled) }.store(in: &viewModelListeners) viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) }.store(in: &viewModelListeners) viewModel.$jumpForwardLength.sink { newJumpForwardLength in self.refreshJumpForwardOverlayView(with: newJumpForwardLength) }.store(in: &viewModelListeners) } func setMediaPlayerTimeAtCurrentSlider() { // Necessary math as VLCMediaPlayer doesn't work well // by just setting the position let runTimeTicks = viewModel.item.runTimeTicks ?? 0 let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) let videoDuration = Double(runTimeTicks / 10_000_000) let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) let newPositionOffset = secondsScrubbedTo - videoPosition if newPositionOffset > 0 { vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) } else { vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) } } } // MARK: Show/Hide Overlay extension VLCPlayerViewController { private func showOverlay() { guard let overlayHostingController = currentOverlayHostingController else { return } guard overlayHostingController.view.alpha != 1 else { return } overlayHostingController.view.alpha = 1 withAnimation(.easeInOut(duration: 0.2)) { [weak self] in self?.viewModel.isHiddenOverlay = false } } private func hideOverlay() { guard !UIAccessibility.isVoiceOverRunning else { return } guard let overlayHostingController = currentOverlayHostingController else { return } guard overlayHostingController.view.alpha != 0 else { return } // for gestures UX exchangeOverlayView(isBringToFrontThanGestureView: false) UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { overlayHostingController.view.alpha = 0 } completion: { [weak self] _ in guard let self = self else { return } self.exchangeOverlayView(isBringToFrontThanGestureView: true) self.viewModel.isHiddenOverlay = true } } private func toggleOverlay() { if viewModel.isHiddenOverlay { showOverlay() } else { hideOverlay() } } } // MARK: Show/Hide Locked Overlay extension VLCPlayerViewController { private func showLockedOverlay() { guard lockedOverlayView.alpha != 1 else { return } UIView.animate(withDuration: 0.2) { self.lockedOverlayView.alpha = 1 } } private func hideLockedOverlay() { guard !UIAccessibility.isVoiceOverRunning else { return } guard lockedOverlayView.alpha != 0 else { return } UIView.animate(withDuration: 0.2) { self.lockedOverlayView.alpha = 0 } } private func toggleLockedOverlay() { if lockedOverlayView.alpha < 1 { showLockedOverlay() } else { hideLockedOverlay() } } } // MARK: Show/Hide System Control extension VLCPlayerViewController { private func showBrightnessOverlay() { guard !displayingOverlay else { return } let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? .withTintColor(.white) let attributedString = NSMutableAttributedString() attributedString.append(.init(attachment: imageAttachment)) attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) systemControlOverlayLabel.attributedText = attributedString systemControlOverlayLabel.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { self.systemControlOverlayLabel.alpha = 1 } } private func showVolumeOverlay() { guard !displayingOverlay, let value = (volumeView.subviews.first as? UISlider)?.value else { return } let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? .withTintColor(.white) let attributedString = NSMutableAttributedString() attributedString.append(.init(attachment: imageAttachment)) attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) systemControlOverlayLabel.attributedText = attributedString systemControlOverlayLabel.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { self.systemControlOverlayLabel.alpha = 1 } } private func showSliderOverlay() { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage( systemName: "clock.arrow.circlepath", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48) )? .withTintColor(.white) let attributedString = NSMutableAttributedString() attributedString.append(.init(attachment: imageAttachment)) attributedString.append(.init(string: " \(viewModel.scrubbingTimeLabelText) (\(viewModel.leftLabelText))")) systemControlOverlayLabel.attributedText = attributedString systemControlOverlayLabel.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { self.systemControlOverlayLabel.alpha = 1 } } private func hideSystemControlOverlay() { UIView.animate(withDuration: 0.75) { self.systemControlOverlayLabel.alpha = 0 } } } // MARK: Show/Hide Jump extension VLCPlayerViewController { private func flashJumpBackwardOverlay() { guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } currentJumpBackwardOverlayView.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { currentJumpBackwardOverlayView.alpha = 1 } completion: { _ in self.hideJumpBackwardOverlay() } } private func hideJumpBackwardOverlay() { guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } UIView.animate(withDuration: 0.3) { currentJumpBackwardOverlayView.alpha = 0 } } private func flashJumpFowardOverlay() { guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } currentJumpForwardOverlayView.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { currentJumpForwardOverlayView.alpha = 1 } completion: { _ in self.hideJumpForwardOverlay() } } private func hideJumpForwardOverlay() { guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } UIView.animate(withDuration: 0.3) { currentJumpForwardOverlayView.alpha = 0 } } } // MARK: Hide/Show Chapters extension VLCPlayerViewController { private func showChaptersOverlay() { guard let overlayHostingController = currentChapterOverlayHostingController else { return } guard overlayHostingController.view.alpha != 1 else { return } UIView.animate(withDuration: 0.2) { overlayHostingController.view.alpha = 1 } } private func hideChaptersOverlay() { guard let overlayHostingController = currentChapterOverlayHostingController else { return } guard overlayHostingController.view.alpha != 0 else { return } UIView.animate(withDuration: 0.2) { overlayHostingController.view.alpha = 0 } } } // MARK: OverlayTimer extension VLCPlayerViewController { private func restartOverlayDismissTimer(interval: Double = 3) { overlayDismissTimer?.invalidate() overlayDismissTimer = Timer.scheduledTimer( timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false ) } @objc private func dismissTimerFired() { hideOverlay() hideLockedOverlay() } private func stopOverlayDismissTimer() { overlayDismissTimer?.invalidate() } } // MARK: VLCMediaPlayerDelegate extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerStateChanged func mediaPlayerStateChanged(_ aNotification: Notification) { // Don't show buffering if paused, usually here while scrubbing if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { return } viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { didSelectPlayNextItem() } else { didSelectClose() } } } // MARK: mediaPlayerTimeChanged func mediaPlayerTimeChanged(_ aNotification: Notification) { if !viewModel.sliderIsScrubbing { viewModel.sliderPercentage = Double(vlcMediaPlayer.position) } // Have to manually set playing because VLCMediaPlayer doesn't // properly set it itself if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { viewModel.playerState = VLCMediaPlayerState.playing } // If needing to fix subtitle streams during playback if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex), viewModel.subtitlesEnabled { didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) } // If needing to fix audio stream during playback if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) } lastPlayerTicks = currentPlayerTicks // Send progress report every 5 seconds if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } } } // MARK: PlayerOverlayDelegate and more extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectAudioStream(index: Int) { vlcMediaPlayer.currentAudioTrackIndex = Int32(index) viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } /// Do not call when setting to index -1 func didSelectSubtitleStream(index: Int) { viewModel.subtitlesEnabled = true vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: index) viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } @objc func didSelectClose() { vlcMediaPlayer.stop() viewModel.sendStopReport() dismiss(animated: true, completion: nil) } func didToggleSubtitles(newValue: Bool) { if newValue { vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 } } // TODO: Implement properly in overlays func didSelectMenu() { stopOverlayDismissTimer() } // TODO: Implement properly in overlays func didDeselectMenu() { restartOverlayDismissTimer() } @objc func didSelectBackward() { flashJumpBackwardOverlay() vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) if displayingOverlay { restartOverlayDismissTimer() } viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } @objc func didSelectForward() { flashJumpFowardOverlay() vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) if displayingOverlay { restartOverlayDismissTimer() } viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } @objc func didSelectMain() { switch viewModel.playerState { case .buffering: vlcMediaPlayer.play() restartOverlayDismissTimer() case .playing: viewModel.sendPauseReport(paused: true) vlcMediaPlayer.pause() restartOverlayDismissTimer(interval: 5) case .paused: viewModel.sendPauseReport(paused: false) vlcMediaPlayer.play() restartOverlayDismissTimer() default: () } } func didGenerallyTap(point: CGPoint? = nil) { if isGesturesLocked { toggleLockedOverlay() } else { if viewModel.jumpGesturesEnabled, let point = point { let tempStack = tapLocationStack tapLocationStack.append(point) if isSameLocationWithLast(point: point, in: tempStack) { isTapWhenJumping = false isJumping = true tapLocationStack.removeAll() jumpingCompletionWork?.cancel() jumpingCompletionWork = DispatchWorkItem(block: { [weak self] in guard let self = self else { return } self.isJumping = false guard self.isTapWhenJumping else { return } self.isTapWhenJumping = false self.toggleOverlay() }) DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: jumpingCompletionWork!) hideOverlay() if point.x > (mainGestureView.frame.width / 2) { didSelectForward() } else { didSelectBackward() } return } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { [weak self] in guard let self = self else { return } guard !self.tapLocationStack.isEmpty else { return } self.tapLocationStack.removeFirst() } } } guard !isJumping else { isTapWhenJumping = true return } toggleOverlay() } restartOverlayDismissTimer(interval: 5) } private func isSameLocationWithLast(point: CGPoint, in stack: [CGPoint]) -> Bool { guard let last = stack.last else { return false } if last.x > (mainGestureView.frame.width / 2) { return point.x > (mainGestureView.frame.width / 2) } else { return point.x <= (mainGestureView.frame.width / 2) } } func didBeginScrubbing() { stopOverlayDismissTimer() } func didEndScrubbing() { setMediaPlayerTimeAtCurrentSlider() restartOverlayDismissTimer() viewModel.sendProgressReport() lastProgressReportTicks = currentPlayerTicks } @objc func didSelectPlayPreviousItem() { if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) startPlayback() } } @objc func didSelectPlayNextItem() { if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) startPlayback() } } @objc func didSelectPreviousPlaybackSpeed() { if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { viewModel.playbackSpeed = previousPlaybackSpeed } } @objc func didSelectNextPlaybackSpeed() { if let nextPlaybackSpeed = viewModel.playbackSpeed.next { viewModel.playbackSpeed = nextPlaybackSpeed } } @objc func didSelectNormalPlaybackSpeed() { viewModel.playbackSpeed = .one } func didSelectChapters() { if displayingChapterOverlay { hideChaptersOverlay() } else { hideOverlay() showChaptersOverlay() } } func didSelectChapter(_ chapter: ChapterInfo) { let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) let newPositionOffset = chapterSeconds - videoPosition if newPositionOffset > 0 { vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) } else { vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) } viewModel.sendProgressReport() } func didSelectScreenFill() { isScreenFilled.toggle() if isScreenFilled { fillScreen() } else { shrinkScreen() } } private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { let videoSize = vlcMediaPlayer.videoSize let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) let scale: CGFloat if fillSize.height > screenSize.height { scale = fillSize.height / screenSize.height } else { scale = fillSize.width / screenSize.width } UIView.animate(withDuration: 0.2) { self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) } } private func shrinkScreen() { UIView.animate(withDuration: 0.2) { self.videoContentView.transform = .identity } } func getScreenFilled() -> Bool { isScreenFilled } func isVideoAspectRatioGreater() -> Bool { let screenSize = UIScreen.main.bounds.size let videoSize = vlcMediaPlayer.videoSize let screenAspectRatio = screenSize.width / screenSize.height let videoAspectRatio = videoSize.width / videoSize.height return videoAspectRatio > screenAspectRatio } }