diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 65495c6f..8f198282 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -61,6 +61,24 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { HStack(spacing: 20) { + if viewModel.showAdjacentItems { + Button { + viewModel.playerOverlayDelegate?.didSelectPreviousItem() + } label: { + Image(systemName: "chevron.left.circle") + } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + + Button { + viewModel.playerOverlayDelegate?.didSelectNextItem() + } label: { + Image(systemName: "chevron.right.circle") + } + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + if viewModel.shouldShowGoogleCast { Button { viewModel.playerOverlayDelegate?.didSelectGoogleCast() @@ -205,8 +223,8 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { viewModel.playerOverlayDelegate?.didSelectMain() } label: { mainButtonView - .padding(.horizontal, 5) .frame(minWidth: 30, maxWidth: 30) + .padding(.horizontal, 10) } Button { @@ -291,7 +309,8 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { subtitlesEnabled: true, sliderPercentage: 0.432, selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1)) + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true)) } .previewInterfaceOrientation(.landscapeLeft) } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index bb26372a..dd494e50 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -239,20 +239,11 @@ struct VLCPlayerOverlayView_Previews: PreviewProvider { subtitlesEnabled: true, sliderPercentage: 0.0, selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1)) + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true)) } .previewInterfaceOrientation(.landscapeLeft) } } -extension HorizontalAlignment { - - private struct EpisodeSeriesTitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) - -} + diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift index 17281c60..720266c7 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift @@ -12,3 +12,15 @@ import SwiftUI protocol VideoPlayerOverlay: View { var viewModel: VideoPlayerViewModel { get set } } + +extension HorizontalAlignment { + + private struct EpisodeSeriesTitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) + +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index 338ddd85..4b4fce33 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -27,4 +27,7 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) + + func didSelectPreviousItem() + func didSelectNextItem() } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index d76b792d..33b7ee5d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -15,14 +15,18 @@ import MobileVLCKit import SwiftUI import UIKit +// TODO: Make the VLC player layer a view +// This will allow changing media and putting the view somewhere else +// in a compact state, like a small viewer while navigating the app + class VLCPlayerViewController: UIViewController { // MARK: variables - private let viewModel: VideoPlayerViewModel + private var viewModel: VideoPlayerViewModel private var vlcMediaPlayer = VLCMediaPlayer() - private var lastPlayerTicks: Int64 - private var lastProgressReportTicks: Int64 + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 private var cancellables = Set() private var overlayDismissTimer: Timer? @@ -31,7 +35,7 @@ class VLCPlayerViewController: UIViewController { } private var displayingOverlay: Bool { - return overlayHostingController.view.alpha > 0 + return currentOverlayHostingController?.view.alpha ?? 0 > 0 } private var jumpForwardLength: VideoPlayerJumpLength { @@ -44,7 +48,7 @@ class VLCPlayerViewController: UIViewController { private lazy var videoContentView = makeVideoContentView() private lazy var tapGestureView = makeTapGestureView() - private lazy var overlayHostingController = makeOverlayHostingController() + private var currentOverlayHostingController: UIHostingController? // MARK: init @@ -52,9 +56,6 @@ class VLCPlayerViewController: UIViewController { self.viewModel = viewModel - self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 - self.lastProgressReportTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 - super.init(nibName: nil, bundle: nil) viewModel.playerOverlayDelegate = self @@ -67,12 +68,6 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) view.addSubview(tapGestureView) - - addChild(overlayHostingController) - overlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - overlayHostingController.view.backgroundColor = UIColor.black.withAlphaComponent(0.2) - view.addSubview(overlayHostingController.view) - overlayHostingController.didMove(toParent: self) } private func setupConstraints() { @@ -88,12 +83,6 @@ class VLCPlayerViewController: UIViewController { tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) ]) - NSLayoutConstraint.activate([ - overlayHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - overlayHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - overlayHostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor), - overlayHostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor) - ]) } // MARK: viewWillAppear @@ -120,39 +109,15 @@ class VLCPlayerViewController: UIViewController { setupSubviews() setupConstraints() - setupViewModelListeners() - view.backgroundColor = .black - setupMediaPlayer() - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners() { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &cancellables) + // These are kept outside of 'setupMediaPlayer' such that + // they aren't unnecessarily set more than once + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - viewModel.$screenFilled.sink { shouldFill in - self.changeFill(to: shouldFill) - }.store(in: &cancellables) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing(position: self.viewModel.sliderPercentage) - } - }.store(in: &cancellables) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &cancellables) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &cancellables) + setupMediaPlayer(newViewModel: viewModel) } private func changeFill(to shouldFill: Bool) { @@ -177,7 +142,6 @@ class VLCPlayerViewController: UIViewController { super.viewDidAppear(animated) startPlayback() - restartOverlayDismissTimer() } // MARK: subviews @@ -193,7 +157,6 @@ class VLCPlayerViewController: UIViewController { private func makeTapGestureView() -> UIView { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) @@ -220,33 +183,93 @@ class VLCPlayerViewController: UIViewController { self.didSelectBackward() } - private func makeOverlayHostingController() -> UIHostingController { - let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) - return UIHostingController(rootView: overlayView) + // MARK: setupOverlayHostingController + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + if let currentOverlayHostingController = currentOverlayHostingController { + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + self.currentOverlayHostingController = nil + } + + let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + 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) + ]) + + self.currentOverlayHostingController = newOverlayHostingController + + // 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 VLCPlayerViewController { - func setupMediaPlayer() { + /// 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) { - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + stopOverlayDismissTimer() + + // UX improvement + (vlcMediaPlayer.drawable as! UIView).isHidden = true + + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + cancellables.forEach({ $0.cancel() }) + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + vlcMediaPlayer.media = nil + } + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - let media = VLCMedia(url: viewModel.streamURL) + let media = VLCMedia(url: newViewModel.streamURL) 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 + + viewModel = newViewModel } func startPlayback() { + // UX improvement + (vlcMediaPlayer.drawable as! UIView).isHidden = false + vlcMediaPlayer.play() viewModel.sendPlayReport() + restartOverlayDismissTimer() + // 1 second = 10,000,000 ticks let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 @@ -261,28 +284,62 @@ extension VLCPlayerViewController { } } } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &cancellables) + + viewModel.$screenFilled.sink { shouldFill in + self.changeFill(to: shouldFill) + }.store(in: &cancellables) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing(position: self.viewModel.sliderPercentage) + } + }.store(in: &cancellables) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &cancellables) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &cancellables) + } } // MARK: Show/Hide Overlay extension VLCPlayerViewController { private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + guard overlayHostingController.view.alpha != 1 else { return } UIView.animate(withDuration: 0.2) { - self.overlayHostingController.view.alpha = 1 + 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) { - self.overlayHostingController.view.alpha = 0 + overlayHostingController.view.alpha = 0 } } private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + if overlayHostingController.view.alpha < 1 { showOverlay() } else { @@ -464,4 +521,14 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { self.lastProgressReportTicks = currentPlayerTicks } + + func didSelectPreviousItem() { + setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!) + startPlayback() + } + + func didSelectNextItem() { + setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) + startPlayback() + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index f3065cd0..b5b3a969 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -95,7 +95,8 @@ extension BaseItemDto { subtitlesEnabled: defaultAudioStream?.index != nil, sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + showAdjacentItems: true) return videoPlayerViewModel }) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index dfb16919..7fcecd21 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -37,6 +37,9 @@ final class VideoPlayerViewModel: ViewModel { @Published var sliderIsScrubbing: Bool = false @Published var selectedAudioStreamIndex: Int @Published var selectedSubtitleStreamIndex: Int + @Published var showAdjacentItems: Bool + @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? let item: BaseItemDto let title: String @@ -67,6 +70,8 @@ final class VideoPlayerViewModel: ViewModel { // Necessary PassthroughSubject to capture manual scrubbing from sliders let sliderScrubbingSubject = PassthroughSubject() + // MARK: init + init(item: BaseItemDto, title: String, subtitle: String?, @@ -83,7 +88,8 @@ final class VideoPlayerViewModel: ViewModel { subtitlesEnabled: Bool, sliderPercentage: Double, selectedAudioStreamIndex: Int, - selectedSubtitleStreamIndex: Int) { + selectedSubtitleStreamIndex: Int, + showAdjacentItems: Bool) { self.item = item self.title = title self.subtitle = subtitle @@ -101,6 +107,7 @@ final class VideoPlayerViewModel: ViewModel { self.sliderPercentage = sliderPercentage self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + self.showAdjacentItems = showAdjacentItems super.init() @@ -132,7 +139,87 @@ final class VideoPlayerViewModel: ViewModel { return timeText } +} + +// MARK: Adjacent Items +extension VideoPlayerViewModel { + func getAdjacentEpisodes() { + guard let seriesID = item.seriesId, item.itemType == .episode else { return } + + TvShowsAPI.getEpisodes(seriesId: seriesID, + userId: SessionManager.main.currentLogin.user.id, + adjacentTo: item.id, + limit: 3) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle + + // State 1 + guard let items = response.items, items.count > 1 else { return } + + if items.count == 2 { + if items[0].id == self.item.id { + // State 2 + let nextItem = items[1] + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } else { + // State 3 + let previousItem = items[0] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + } else { + // State 4 + + let previousItem = items[0] + let nextItem = items[2] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + }) + .store(in: &cancellables) + } +} + +// MARK: Reports +extension VideoPlayerViewModel { + + + // MARK: sendPlayReport func sendPlayReport() { self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 @@ -168,6 +255,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendPauseReport func sendPauseReport(paused: Bool) { let startInfo = PlaybackStartInfo(canSeek: true, item: item, @@ -200,6 +288,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendProgressReport func sendProgressReport() { let progressInfo = PlaybackProgressInfo(canSeek: true, @@ -232,6 +321,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendStopReport func sendStopReport() { let stopInfo = PlaybackStopInfo(item: item,