Duplicate player for LiveTV
This commit is contained in:
parent
02c08598be
commit
46f069698d
|
@ -25,8 +25,8 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable {
|
|||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -20,8 +20,8 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable {
|
|||
@Route(.fullScreen)
|
||||
var videoPlayer = makeVideoPlayer
|
||||
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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<tvOSLiveTVOverlay>?
|
||||
private var currentOverlayContentHostingController: UIHostingController<SmallMediaStreamSelectionView>?
|
||||
private var currentConfirmCloseHostingController: UIHostingController<ConfirmCloseOverlay>?
|
||||
|
||||
// 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<UIPress>, 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()
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = "<group>"; };
|
||||
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = "<group>"; };
|
||||
C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = "<group>"; };
|
||||
C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = "<group>"; };
|
||||
C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = "<group>"; };
|
||||
C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
|
||||
C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = "<group>"; };
|
||||
C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = "<group>"; };
|
||||
C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVLibrariesCoordinator.swift; sourceTree = "<group>"; };
|
||||
|
@ -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 */,
|
||||
|
|
Loading…
Reference in New Issue