diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 2f390c9c..77f80de8 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -25,8 +25,8 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { NavigationViewCoordinator(ItemCoordinator(item: item)) } - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) } @ViewBuilder diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index 9c08a7ca..095b1ece 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -20,8 +20,8 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable { @Route(.fullScreen) var videoPlayer = makeVideoPlayer - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) } @ViewBuilder diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift new file mode 100644 index 00000000..dbd8b53b --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift @@ -0,0 +1,34 @@ +// +// 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 Defaults +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + + @Root + var start = makeStart + + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder + func makeStart() -> some View { + LiveTVVideoPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift new file mode 100644 index 00000000..16d6cedd --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -0,0 +1,900 @@ +// +// 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 SwiftUI +import TVVLCKit +import UIKit + +// TODO: Look at making the VLC player layer a view + +class LiveTVPlayerViewController: 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 confirmCloseOverlayDismissTimer: Timer? + + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingContentOverlay: Bool { + currentOverlayContentHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingConfirmClose: Bool { + currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() + private var currentOverlayHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? + private var currentConfirmCloseHostingController: UIHostingController? + + // 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(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) + + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 + } + + 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([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + didSelectClose() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + setupMediaPlayer(newViewModel: viewModel) + + setupPanGestureRecognizer() + + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) + + 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() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func setupPanGestureRecognizer() { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + } + + // MARK: pressesBegan + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch buttonPress { + case .menu: () // Captured by other recognizer + case .playPause: + hideConfirmCloseOverlay() + + didSelectMain() + case .select: + hideConfirmCloseOverlay() + + didGenerallyTap() + case .upArrow: + hideConfirmCloseOverlay() + case .downArrow: + hideConfirmCloseOverlay() + + if Defaults[.downActionShowsMenu] { + if !displayingContentOverlay && !displayingOverlay { + didSelectMenu() + } + } + case .leftArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectBackward() + } + case .rightArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectForward() + } + case .pageUp: () + case .pageDown: () + @unknown default: () + } + } + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + // MARK: didPressMenu + + @objc + private func didPressMenu() { + if displayingOverlay { + hideOverlay() + } else if displayingContentOverlay { + hideOverlayContent() + } else if viewModel.confirmClose && !displayingConfirmClose { + + showConfirmCloseOverlay() + restartConfirmCloseDismissTimer() + + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + + @objc + private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { + if displayingOverlay { + restartOverlayDismissTimer() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + + // Main 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 = tvOSLiveTVOverlay(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 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // Media Stream selection + if let currentOverlayContentHostingController = currentOverlayContentHostingController { + currentOverlayContentHostingController.view.isHidden = true + + currentOverlayContentHostingController.view.removeFromSuperview() + currentOverlayContentHostingController.removeFromParent() + } + + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) + + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) + + newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayContentHostingController.view.backgroundColor = UIColor.clear + + newOverlayContentHostingController.view.alpha = 0 + + addChild(newOverlayContentHostingController) + view.addSubview(newOverlayContentHostingController.view) + newOverlayContentHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + self.currentOverlayContentHostingController = newOverlayContentHostingController + + // Confirm close + if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { + currentConfirmCloseHostingController.view.isHidden = true + + currentConfirmCloseHostingController.view.removeFromSuperview() + currentConfirmCloseHostingController.removeFromParent() + } + + let newConfirmCloseOverlay = ConfirmCloseOverlay() + + let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) + + newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newConfirmCloseHostingController.view.backgroundColor = UIColor.clear + + newConfirmCloseHostingController.view.alpha = 0 + + addChild(newConfirmCloseHostingController) + view.addSubview(newConfirmCloseHostingController.view) + newConfirmCloseHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + self.currentConfirmCloseHostingController = newConfirmCloseHostingController + + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true + } +} + +// MARK: setupMediaPlayer + +extension LiveTVPlayerViewController { + + /// 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.setSubtitleSize(Defaults[.subtitleSize]) + + stopOverlayDismissTimer() + + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + // TODO: Custom buffer/cache amounts + + 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.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.shared.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } + + // 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(interval: 5) + } + + // 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) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + 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 LiveTVPlayerViewController { + + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + if displayingOverlay { + hideOverlay() + } else { + showOverlay() + } + } + + private func showOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 1 else { return } + + currentOverlayContentHostingController.view.setNeedsFocusUpdate() + currentOverlayContentHostingController.setNeedsFocusUpdate() + setNeedsFocusUpdate() + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 1 + } + } + + private func hideOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 0 else { return } + + setNeedsFocusUpdate() + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 0 + } + } +} + +// MARK: Show/Hide Jump + +extension LiveTVPlayerViewController { + + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } +} + +// MARK: Show/Hide Confirm close + +extension LiveTVPlayerViewController { + + private func showConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.2) { + currentConfirmCloseHostingController.view.alpha = 1 + } + } + + private func hideConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.5) { + currentConfirmCloseHostingController.view.alpha = 0 + } + } +} + +// MARK: OverlayTimer + +extension LiveTVPlayerViewController { + + private func restartOverlayDismissTimer(interval: Double = 5) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), + userInfo: nil, repeats: false) + } + + @objc + private func dismissTimerFired() { + hideOverlay() + } + + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } +} + +// MARK: Confirm Close Overlay Timer + +extension LiveTVPlayerViewController { + + private func restartConfirmCloseDismissTimer() { + self.confirmCloseOverlayDismissTimer?.invalidate() + self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, + selector: #selector(confirmCloseTimerFired), userInfo: nil, + repeats: false) + } + + @objc + private func confirmCloseTimerFired() { + hideConfirmCloseOverlay() + } + + private func stopConfirmCloseDismissTimer() { + confirmCloseOverlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate + +extension LiveTVPlayerViewController: 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.selectedSubtitleStreamIndex && + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + 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 + +extension LiveTVPlayerViewController: 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 = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + func didSelectMenu() { + stopOverlayDismissTimer() + + hideOverlay() + showOverlayContent() + } + + func didSelectBackward() { + + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectForward() { + + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectMain() { + + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + + showOverlay() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } + + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } + + 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() + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift new file mode 100644 index 00000000..fc92bbb2 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift @@ -0,0 +1,24 @@ +// +// 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 SwiftUI +import UIKit + +struct LiveTVVideoPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVPlayerViewController + + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + + LiveTVPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift new file mode 100644 index 00000000..cbeca18e --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift @@ -0,0 +1,171 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct tvOSLiveTVOverlay: View { + + @ObservedObject + var viewModel: VideoPlayerViewModel + @Default(.downActionShowsMenu) + var downActionShowsMenu + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.circle") + case .playing: + Image(systemName: "pause.circle") + default: + ProgressView() + } + } + + var body: some View { + ZStack(alignment: .bottom) { + + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: viewModel.subtitle == nil ? 180 : 210) + + VStack { + + Spacer() + + HStack(alignment: .bottom) { + + VStack(alignment: .leading) { + if let subtitle = viewModel.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + } + + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + } + + Spacer() + + if viewModel.shouldShowPlayPreviousItem { + SFSymbolButton(systemName: "chevron.left.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowPlayNextItem { + SFSymbolButton(systemName: "chevron.right.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowAutoPlay { + if viewModel.autoplayEnabled { + SFSymbolButton(systemName: "play.circle.fill") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "stop.circle") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + + if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + + if !downActionShowsMenu { + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + .offset(x: 0, y: 10) + + SliderView(viewModel: viewModel) + .frame(maxHeight: 40) + + HStack { + + HStack(spacing: 10) { + mainButtonView + .frame(maxWidth: 40, maxHeight: 40) + + Text(viewModel.leftLabelText) + } + + Spacer() + + Text(viewModel.rightLabelText) + } + .offset(x: 0, y: -10) + } + } + .foregroundColor(.white) + } +} + +struct tvOSLiveTVOverlay_Previews: PreviewProvider { + + static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + directStreamURL: URL(string: "www.apple.com")!, + transcodedStreamURL: nil, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true, + container: "", + filename: nil, + versionName: nil) + + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() + + tvOSLiveTVOverlay(viewModel: videoPlayerViewModel) + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 1037861e..1d7650c1 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -217,6 +217,10 @@ C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; }; C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; }; C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; }; + C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */; }; + C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; + C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; + C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; @@ -652,6 +656,10 @@ C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesLibrariesCoordinator.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = ""; }; C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = ""; }; + C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; + C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; + C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; + C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVLibrariesCoordinator.swift; sourceTree = ""; }; @@ -880,6 +888,8 @@ E178859C2780F5300094FBCF /* tvOSSLider */, E17885A7278130690094FBCF /* Overlays */, E1C812C8277AE40900918266 /* VideoPlayerView.swift */, + C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */, + C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */, E1384943278036C70024FB48 /* VLCPlayerViewController.swift */, E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */, ); @@ -1554,6 +1564,7 @@ children = ( E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */, E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, + C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */, E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, ); path = Overlays; @@ -1672,6 +1683,7 @@ isa = PBXGroup; children = ( 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, + C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */, E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, ); path = VideoPlayerCoordinator; @@ -2162,6 +2174,7 @@ 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, E103A6A9278AB6FF00820EC7 /* CinematicNextUpCardView.swift in Sources */, E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, + C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */, E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */, C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */, @@ -2207,6 +2220,7 @@ E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */, + C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, @@ -2225,6 +2239,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */, + C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */, E10D87DF278510E400BD264C /* PosterSize.swift in Sources */, E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, @@ -2265,6 +2280,7 @@ E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */, 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, + C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */, E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */,