initial previous and next item feature

This commit is contained in:
Ethan Pippin 2021-12-29 12:33:43 -07:00
parent 99445e387c
commit fe0c8ee03b
7 changed files with 261 additions and 78 deletions

View File

@ -61,6 +61,24 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay {
HStack(spacing: 20) { 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 { if viewModel.shouldShowGoogleCast {
Button { Button {
viewModel.playerOverlayDelegate?.didSelectGoogleCast() viewModel.playerOverlayDelegate?.didSelectGoogleCast()
@ -205,8 +223,8 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay {
viewModel.playerOverlayDelegate?.didSelectMain() viewModel.playerOverlayDelegate?.didSelectMain()
} label: { } label: {
mainButtonView mainButtonView
.padding(.horizontal, 5)
.frame(minWidth: 30, maxWidth: 30) .frame(minWidth: 30, maxWidth: 30)
.padding(.horizontal, 10)
} }
Button { Button {
@ -291,7 +309,8 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider {
subtitlesEnabled: true, subtitlesEnabled: true,
sliderPercentage: 0.432, sliderPercentage: 0.432,
selectedAudioStreamIndex: -1, selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1)) selectedSubtitleStreamIndex: -1,
showAdjacentItems: true))
} }
.previewInterfaceOrientation(.landscapeLeft) .previewInterfaceOrientation(.landscapeLeft)
} }

View File

@ -239,20 +239,11 @@ struct VLCPlayerOverlayView_Previews: PreviewProvider {
subtitlesEnabled: true, subtitlesEnabled: true,
sliderPercentage: 0.0, sliderPercentage: 0.0,
selectedAudioStreamIndex: -1, selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1)) selectedSubtitleStreamIndex: -1,
showAdjacentItems: true))
} }
.previewInterfaceOrientation(.landscapeLeft) .previewInterfaceOrientation(.landscapeLeft)
} }
} }
extension HorizontalAlignment {
private struct EpisodeSeriesTitleAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.leading]
}
}
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self)
}

View File

@ -12,3 +12,15 @@ import SwiftUI
protocol VideoPlayerOverlay: View { protocol VideoPlayerOverlay: View {
var viewModel: VideoPlayerViewModel { get set } 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)
}

View File

@ -27,4 +27,7 @@ protocol PlayerOverlayDelegate {
func didSelectAudioStream(index: Int) func didSelectAudioStream(index: Int)
func didSelectSubtitleStream(index: Int) func didSelectSubtitleStream(index: Int)
func didSelectPreviousItem()
func didSelectNextItem()
} }

View File

@ -15,14 +15,18 @@ import MobileVLCKit
import SwiftUI import SwiftUI
import UIKit 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 { class VLCPlayerViewController: UIViewController {
// MARK: variables // MARK: variables
private let viewModel: VideoPlayerViewModel private var viewModel: VideoPlayerViewModel
private var vlcMediaPlayer = VLCMediaPlayer() private var vlcMediaPlayer = VLCMediaPlayer()
private var lastPlayerTicks: Int64 private var lastPlayerTicks: Int64 = 0
private var lastProgressReportTicks: Int64 private var lastProgressReportTicks: Int64 = 0
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var overlayDismissTimer: Timer? private var overlayDismissTimer: Timer?
@ -31,7 +35,7 @@ class VLCPlayerViewController: UIViewController {
} }
private var displayingOverlay: Bool { private var displayingOverlay: Bool {
return overlayHostingController.view.alpha > 0 return currentOverlayHostingController?.view.alpha ?? 0 > 0
} }
private var jumpForwardLength: VideoPlayerJumpLength { private var jumpForwardLength: VideoPlayerJumpLength {
@ -44,7 +48,7 @@ class VLCPlayerViewController: UIViewController {
private lazy var videoContentView = makeVideoContentView() private lazy var videoContentView = makeVideoContentView()
private lazy var tapGestureView = makeTapGestureView() private lazy var tapGestureView = makeTapGestureView()
private lazy var overlayHostingController = makeOverlayHostingController() private var currentOverlayHostingController: UIHostingController<VLCPlayerCompactOverlayView>?
// MARK: init // MARK: init
@ -52,9 +56,6 @@ class VLCPlayerViewController: UIViewController {
self.viewModel = viewModel self.viewModel = viewModel
self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0
self.lastProgressReportTicks = viewModel.item.userData?.playbackPositionTicks ?? 0
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
viewModel.playerOverlayDelegate = self viewModel.playerOverlayDelegate = self
@ -67,12 +68,6 @@ class VLCPlayerViewController: UIViewController {
private func setupSubviews() { private func setupSubviews() {
view.addSubview(videoContentView) view.addSubview(videoContentView)
view.addSubview(tapGestureView) 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() { private func setupConstraints() {
@ -88,12 +83,6 @@ class VLCPlayerViewController: UIViewController {
tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) 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 // MARK: viewWillAppear
@ -120,39 +109,15 @@ class VLCPlayerViewController: UIViewController {
setupSubviews() setupSubviews()
setupConstraints() setupConstraints()
setupViewModelListeners()
view.backgroundColor = .black view.backgroundColor = .black
setupMediaPlayer() // These are kept outside of 'setupMediaPlayer' such that
} // they aren't unnecessarily set more than once
vlcMediaPlayer.delegate = self
// MARK: setupViewModelListeners vlcMediaPlayer.drawable = videoContentView
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
private func setupViewModelListeners() {
viewModel.$playbackSpeed.sink { newSpeed in
self.vlcMediaPlayer.rate = Float(newSpeed.rawValue)
}.store(in: &cancellables)
viewModel.$screenFilled.sink { shouldFill in setupMediaPlayer(newViewModel: viewModel)
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)
} }
private func changeFill(to shouldFill: Bool) { private func changeFill(to shouldFill: Bool) {
@ -177,7 +142,6 @@ class VLCPlayerViewController: UIViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
startPlayback() startPlayback()
restartOverlayDismissTimer()
} }
// MARK: subviews // MARK: subviews
@ -193,7 +157,6 @@ class VLCPlayerViewController: UIViewController {
private func makeTapGestureView() -> UIView { private func makeTapGestureView() -> UIView {
let view = UIView() let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
@ -220,33 +183,93 @@ class VLCPlayerViewController: UIViewController {
self.didSelectBackward() self.didSelectBackward()
} }
private func makeOverlayHostingController() -> UIHostingController<VLCPlayerCompactOverlayView> { // MARK: setupOverlayHostingController
let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) {
return UIHostingController(rootView: overlayView)
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 // MARK: setupMediaPlayer
extension VLCPlayerViewController { 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 stopOverlayDismissTimer()
vlcMediaPlayer.drawable = videoContentView
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) // 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("--prefetch-buffer-size=1048576")
media.addOption("--network-caching=5000") media.addOption("--network-caching=5000")
vlcMediaPlayer.media = media vlcMediaPlayer.media = media
setupOverlayHostingController(viewModel: newViewModel)
setupViewModelListeners(viewModel: newViewModel)
newViewModel.getAdjacentEpisodes()
newViewModel.playerOverlayDelegate = self
viewModel = newViewModel
} }
func startPlayback() { func startPlayback() {
// UX improvement
(vlcMediaPlayer.drawable as! UIView).isHidden = false
vlcMediaPlayer.play() vlcMediaPlayer.play()
viewModel.sendPlayReport() viewModel.sendPlayReport()
restartOverlayDismissTimer()
// 1 second = 10,000,000 ticks // 1 second = 10,000,000 ticks
let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 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 // MARK: Show/Hide Overlay
extension VLCPlayerViewController { extension VLCPlayerViewController {
private func showOverlay() { private func showOverlay() {
guard let overlayHostingController = currentOverlayHostingController else { return }
guard overlayHostingController.view.alpha != 1 else { return } guard overlayHostingController.view.alpha != 1 else { return }
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.overlayHostingController.view.alpha = 1 overlayHostingController.view.alpha = 1
} }
} }
private func hideOverlay() { private func hideOverlay() {
guard let overlayHostingController = currentOverlayHostingController else { return }
guard overlayHostingController.view.alpha != 0 else { return } guard overlayHostingController.view.alpha != 0 else { return }
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.overlayHostingController.view.alpha = 0 overlayHostingController.view.alpha = 0
} }
} }
private func toggleOverlay() { private func toggleOverlay() {
guard let overlayHostingController = currentOverlayHostingController else { return }
if overlayHostingController.view.alpha < 1 { if overlayHostingController.view.alpha < 1 {
showOverlay() showOverlay()
} else { } else {
@ -464,4 +521,14 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
self.lastProgressReportTicks = currentPlayerTicks self.lastProgressReportTicks = currentPlayerTicks
} }
func didSelectPreviousItem() {
setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!)
startPlayback()
}
func didSelectNextItem() {
setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!)
startPlayback()
}
} }

View File

@ -95,7 +95,8 @@ extension BaseItemDto {
subtitlesEnabled: defaultAudioStream?.index != nil, subtitlesEnabled: defaultAudioStream?.index != nil,
sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
showAdjacentItems: true)
return videoPlayerViewModel return videoPlayerViewModel
}) })

View File

@ -37,6 +37,9 @@ final class VideoPlayerViewModel: ViewModel {
@Published var sliderIsScrubbing: Bool = false @Published var sliderIsScrubbing: Bool = false
@Published var selectedAudioStreamIndex: Int @Published var selectedAudioStreamIndex: Int
@Published var selectedSubtitleStreamIndex: Int @Published var selectedSubtitleStreamIndex: Int
@Published var showAdjacentItems: Bool
@Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel?
@Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel?
let item: BaseItemDto let item: BaseItemDto
let title: String let title: String
@ -67,6 +70,8 @@ final class VideoPlayerViewModel: ViewModel {
// Necessary PassthroughSubject to capture manual scrubbing from sliders // Necessary PassthroughSubject to capture manual scrubbing from sliders
let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>() let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>()
// MARK: init
init(item: BaseItemDto, init(item: BaseItemDto,
title: String, title: String,
subtitle: String?, subtitle: String?,
@ -83,7 +88,8 @@ final class VideoPlayerViewModel: ViewModel {
subtitlesEnabled: Bool, subtitlesEnabled: Bool,
sliderPercentage: Double, sliderPercentage: Double,
selectedAudioStreamIndex: Int, selectedAudioStreamIndex: Int,
selectedSubtitleStreamIndex: Int) { selectedSubtitleStreamIndex: Int,
showAdjacentItems: Bool) {
self.item = item self.item = item
self.title = title self.title = title
self.subtitle = subtitle self.subtitle = subtitle
@ -101,6 +107,7 @@ final class VideoPlayerViewModel: ViewModel {
self.sliderPercentage = sliderPercentage self.sliderPercentage = sliderPercentage
self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedAudioStreamIndex = selectedAudioStreamIndex
self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex
self.showAdjacentItems = showAdjacentItems
super.init() super.init()
@ -132,7 +139,87 @@ final class VideoPlayerViewModel: ViewModel {
return timeText 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() { func sendPlayReport() {
self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000
@ -168,6 +255,7 @@ final class VideoPlayerViewModel: ViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: sendPauseReport
func sendPauseReport(paused: Bool) { func sendPauseReport(paused: Bool) {
let startInfo = PlaybackStartInfo(canSeek: true, let startInfo = PlaybackStartInfo(canSeek: true,
item: item, item: item,
@ -200,6 +288,7 @@ final class VideoPlayerViewModel: ViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: sendProgressReport
func sendProgressReport() { func sendProgressReport() {
let progressInfo = PlaybackProgressInfo(canSeek: true, let progressInfo = PlaybackProgressInfo(canSeek: true,
@ -232,6 +321,7 @@ final class VideoPlayerViewModel: ViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: sendStopReport
func sendStopReport() { func sendStopReport() {
let stopInfo = PlaybackStopInfo(item: item, let stopInfo = PlaybackStopInfo(item: item,