Merge branch 'main' into add-m1-to-device-builder

This commit is contained in:
Ethan Pippin 2022-01-13 15:41:13 -07:00
commit b087adf0e6
16 changed files with 253 additions and 131 deletions

View File

@ -12,7 +12,7 @@ import JellyfinAPI
import UIKit
extension BaseItemDto {
func createVideoPlayerViewModel() -> AnyPublisher<VideoPlayerViewModel, Error> {
func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> {
LogManager.shared.log.debug("Creating video player view model for item: \(id ?? "")")
@ -33,79 +33,95 @@ extension BaseItemDto {
startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true,
playbackInfoDto: playbackInfo)
.map { response -> VideoPlayerViewModel in
let mediaSource = response.mediaSources!.first!
.map { response -> [VideoPlayerViewModel] in
let mediaSources = response.mediaSources!
let audioStreams = mediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = mediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? []
var viewModels: [VideoPlayerViewModel] = []
let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! })
for currentMediaSource in mediaSources {
let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? []
let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 })
let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! })
// MARK: Stream
let defaultSubtitleStream = subtitleStreams
.first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 })
var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
var streamURL: URLComponents
let streamType: ServerStreamType
let streamType: ServerStreamType
if let transcodeURL = currentMediaSource.transcodingUrl {
streamType = .transcode
streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI.appending(transcodeURL))!
} else {
streamType = .direct
streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
streamURL.path = "/Videos/\(self.id!)/stream"
streamURL.addQueryItem(name: "Static", value: "true")
streamURL.addQueryItem(name: "MediaSourceId", value: self.id!)
streamURL.addQueryItem(name: "Tag", value: self.etag)
streamURL.addQueryItem(name: "MinSegments", value: "6")
if let transcodeURL = mediaSource.transcodingUrl {
streamType = .transcode
streamURL.path = transcodeURL
} else {
streamType = .direct
streamURL.path = "/Videos/\(self.id!)/stream"
}
streamURL.addQueryItem(name: "Static", value: "true")
streamURL.addQueryItem(name: "MediaSourceId", value: self.id!)
streamURL.addQueryItem(name: "Tag", value: self.etag)
streamURL.addQueryItem(name: "MinSegments", value: "6")
// MARK: VidoPlayerViewModel Creation
var subtitle: String?
// MARK: Attach media content to self
var modifiedSelfItem = self
modifiedSelfItem.mediaStreams = mediaSource.mediaStreams
// TODO: other forms of media subtitle
if self.itemType == .episode {
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
subtitle = "\(seriesName) - \(episodeLocator)"
if mediaSources.count > 1 {
streamURL.addQueryItem(name: "MediaSourceId", value: currentMediaSource.id)
}
}
// MARK: VidoPlayerViewModel Creation
var subtitle: String?
// MARK: Attach media content to self
var modifiedSelfItem = self
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
// TODO: other forms of media subtitle
if self.itemType == .episode {
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
subtitle = "\(seriesName) - \(episodeLocator)"
}
}
let subtitlesEnabled = defaultSubtitleStream != nil
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
let overlayType = Defaults[.overlayType]
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
var fileName: String?
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
fileName = String(lastInPath)
}
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem,
title: modifiedSelfItem.name ?? "",
subtitle: subtitle,
streamURL: streamURL.url!,
streamType: streamType,
response: response,
audioStreams: audioStreams,
subtitleStreams: subtitleStreams,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
subtitlesEnabled: subtitlesEnabled,
autoplayEnabled: autoplayEnabled,
overlayType: overlayType,
shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
shouldShowPlayNextItem: shouldShowPlayNextItem,
shouldShowAutoPlay: shouldShowAutoPlay,
container: currentMediaSource.container ?? "",
filename: fileName,
versionName: currentMediaSource.name)
viewModels.append(videoPlayerViewModel)
}
let subtitlesEnabled = defaultSubtitleStream != nil
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
let overlayType = Defaults[.overlayType]
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem,
title: modifiedSelfItem.name ?? "",
subtitle: subtitle,
streamURL: streamURL.url!,
streamType: streamType,
response: response,
audioStreams: audioStreams,
subtitleStreams: subtitleStreams,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
subtitlesEnabled: subtitlesEnabled,
autoplayEnabled: autoplayEnabled,
overlayType: overlayType,
shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
shouldShowPlayNextItem: shouldShowPlayNextItem,
shouldShowAutoPlay: shouldShowAutoPlay)
return videoPlayerViewModel
return viewModels
}
.eraseToAnyPublisher()
}

View File

@ -265,11 +265,6 @@ public extension BaseItemDto {
func createMediaItems() -> [ItemDetail] {
var mediaItems: [ItemDetail] = []
if let container = container {
let containerList = container.split(separator: ",").joined(separator: ", ")
mediaItems.append(ItemDetail(title: L10n.containers, content: containerList))
}
if let mediaStreams = mediaStreams {
let audioStreams = mediaStreams.filter { $0.type == .audio }
let subtitleStreams = mediaStreams.filter { $0.type == .subtitle }

View File

@ -102,6 +102,8 @@ internal enum L10n {
internal static let experimental = L10n.tr("Localizable", "experimental")
/// Favorites
internal static let favorites = L10n.tr("Localizable", "favorites")
/// File
internal static let file = L10n.tr("Localizable", "file")
/// Filter Results
internal static let filterResults = L10n.tr("Localizable", "filterResults")
/// Filters

View File

@ -33,8 +33,8 @@ class ItemViewModel: ViewModel {
@Published
var informationItems: [BaseItemDto.ItemDetail]
@Published
var mediaItems: [BaseItemDto.ItemDetail]
var itemVideoPlayerViewModel: VideoPlayerViewModel?
var selectedVideoPlayerViewModel: VideoPlayerViewModel?
var videoPlayerViewModels: [VideoPlayerViewModel] = []
init(item: BaseItemDto) {
self.item = item
@ -48,7 +48,6 @@ class ItemViewModel: ViewModel {
}
informationItems = item.createInformationItems()
mediaItems = item.createMediaItems()
isFavorited = item.userData?.isFavorite ?? false
isWatched = item.userData?.played ?? false
@ -84,9 +83,9 @@ class ItemViewModel: ViewModel {
item.createVideoPlayerViewModel()
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
self.itemVideoPlayerViewModel = videoPlayerViewModel
self.mediaItems = videoPlayerViewModel.item.createMediaItems()
} receiveValue: { viewModels in
self.videoPlayerViewModels = viewModels
self.selectedVideoPlayerViewModel = viewModels.first
}
.store(in: &cancellables)
}

View File

@ -91,6 +91,9 @@ final class VideoPlayerViewModel: ViewModel {
}
}
@Published
var mediaItems: [BaseItemDto.ItemDetail]
// MARK: ShouldShowItems
let shouldShowPlayPreviousItem: Bool
@ -110,6 +113,9 @@ final class VideoPlayerViewModel: ViewModel {
let jumpGesturesEnabled: Bool
let resumeOffset: Bool
let streamType: ServerStreamType
let container: String
let filename: String?
let versionName: String?
// MARK: Experimental
@ -173,7 +179,10 @@ final class VideoPlayerViewModel: ViewModel {
overlayType: OverlayType,
shouldShowPlayPreviousItem: Bool,
shouldShowPlayNextItem: Bool,
shouldShowAutoPlay: Bool)
shouldShowAutoPlay: Bool,
container: String,
filename: String?,
versionName: String?)
{
self.item = item
self.title = title
@ -191,6 +200,9 @@ final class VideoPlayerViewModel: ViewModel {
self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem
self.shouldShowPlayNextItem = shouldShowPlayNextItem
self.shouldShowAutoPlay = shouldShowAutoPlay
self.container = container
self.filename = filename
self.versionName = versionName
self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward]
self.jumpForwardLength = Defaults[.videoPlayerJumpForward]
@ -203,6 +215,8 @@ final class VideoPlayerViewModel: ViewModel {
self.confirmClose = Defaults[.confirmClose]
self.mediaItems = item.createMediaItems()
super.init()
self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100
@ -283,11 +297,13 @@ extension VideoPlayerViewModel {
nextItem.createVideoPlayerViewModel()
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
videoPlayerViewModel.matchSubtitleStream(with: self)
videoPlayerViewModel.matchAudioStream(with: self)
} receiveValue: { viewModels in
for viewModel in viewModels {
viewModel.matchSubtitleStream(with: self)
viewModel.matchAudioStream(with: self)
}
self.nextItemVideoPlayerViewModel = videoPlayerViewModel
self.nextItemVideoPlayerViewModel = viewModels.first
}
.store(in: &self.cancellables)
} else {
@ -297,11 +313,13 @@ extension VideoPlayerViewModel {
previousItem.createVideoPlayerViewModel()
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
videoPlayerViewModel.matchSubtitleStream(with: self)
videoPlayerViewModel.matchAudioStream(with: self)
} receiveValue: { viewModels in
for viewModel in viewModels {
viewModel.matchSubtitleStream(with: self)
viewModel.matchAudioStream(with: self)
}
self.previousItemVideoPlayerViewModel = videoPlayerViewModel
self.previousItemVideoPlayerViewModel = viewModels.first
}
.store(in: &self.cancellables)
}
@ -314,22 +332,26 @@ extension VideoPlayerViewModel {
previousItem.createVideoPlayerViewModel()
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
videoPlayerViewModel.matchSubtitleStream(with: self)
videoPlayerViewModel.matchAudioStream(with: self)
} receiveValue: { viewModels in
for viewModel in viewModels {
viewModel.matchSubtitleStream(with: self)
viewModel.matchAudioStream(with: self)
}
self.previousItemVideoPlayerViewModel = videoPlayerViewModel
self.previousItemVideoPlayerViewModel = viewModels.first
}
.store(in: &self.cancellables)
nextItem.createVideoPlayerViewModel()
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
videoPlayerViewModel.matchSubtitleStream(with: self)
videoPlayerViewModel.matchAudioStream(with: self)
} receiveValue: { viewModels in
for viewModel in viewModels {
viewModel.matchSubtitleStream(with: self)
viewModel.matchAudioStream(with: self)
}
self.nextItemVideoPlayerViewModel = videoPlayerViewModel
self.nextItemVideoPlayerViewModel = viewModels.first
}
.store(in: &self.cancellables)
}
@ -564,3 +586,15 @@ extension VideoPlayerViewModel: Equatable {
lhs.item.userData?.playbackPositionTicks == rhs.item.userData?.playbackPositionTicks
}
}
// MARK: Hashable
extension VideoPlayerViewModel: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(item)
hasher.combine(streamURL)
hasher.combine(filename)
hasher.combine(versionName)
}
}

View File

@ -35,13 +35,15 @@ struct ItemDetailsView: View {
Spacer()
VStack(alignment: .leading, spacing: 20) {
L10n.media.text
.font(.title3)
.padding(.bottom, 5)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
VStack(alignment: .leading, spacing: 20) {
L10n.media.text
.font(.title3)
.padding(.bottom, 5)
ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in
ItemDetail(title: mediaItem.title, content: mediaItem.content)
ForEach(selectedVideoPlayerViewModel.mediaItems, id: \.self.title) { mediaItem in
ItemDetail(title: mediaItem.title, content: mediaItem.content)
}
}
}

View File

@ -20,7 +20,7 @@ struct MediaPlayButtonRowView: View {
HStack {
VStack {
Button {
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!)
} label: {
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
}

View File

@ -59,8 +59,8 @@ struct CinematicItemViewTopRow: View {
// MARK: Play
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
@ -81,9 +81,9 @@ struct CinematicItemViewTopRow: View {
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}

View File

@ -153,7 +153,10 @@ struct tvOSVLCOverlay_Previews: PreviewProvider {
overlayType: .compact,
shouldShowPlayPreviousItem: true,
shouldShowPlayNextItem: true,
shouldShowAutoPlay: true)
shouldShowAutoPlay: true,
container: "",
filename: nil,
versionName: nil)
static var previews: some View {
ZStack {

View File

@ -36,20 +36,34 @@ struct ItemViewDetailsView: View {
.padding(.bottom, 20)
}
if !viewModel.mediaItems.isEmpty {
VStack(alignment: .leading, spacing: 20) {
L10n.media.text
.font(.title3)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 20) {
L10n.media.text
.font(.title3)
.fontWeight(.bold)
ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in
VStack(alignment: .leading, spacing: 2) {
Text(mediaItem.title)
.font(.subheadline)
Text(mediaItem.content)
.font(.subheadline)
.foregroundColor(Color.secondary)
}
VStack(alignment: .leading, spacing: 2) {
L10n.file.text
.font(.subheadline)
Text(viewModel.selectedVideoPlayerViewModel?.filename ?? "--")
.font(.subheadline)
.foregroundColor(Color.secondary)
}
VStack(alignment: .leading, spacing: 2) {
L10n.containers.text
.font(.subheadline)
Text(viewModel.selectedVideoPlayerViewModel?.container ?? "--")
.font(.subheadline)
.foregroundColor(Color.secondary)
}
ForEach(viewModel.selectedVideoPlayerViewModel?.mediaItems ?? [], id: \.self.title) { mediaItem in
VStack(alignment: .leading, spacing: 2) {
Text(mediaItem.title)
.font(.subheadline)
Text(mediaItem.content)
.font(.subheadline)
.foregroundColor(Color.secondary)
}
}
}

View File

@ -34,7 +34,7 @@ struct ItemLandscapeMainView: View {
// MARK: Play
Button {
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
self.itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!)
} label: {
HStack {
Image(systemName: "play.fill")
@ -49,13 +49,13 @@ struct ItemLandscapeMainView: View {
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
.cornerRadius(10)
}
.disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil)
.disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil)
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}

View File

@ -99,7 +99,31 @@ struct ItemLandscapeTopBarView: View {
.disabled(viewModel.isLoading)
}
}
.padding(.leading, 16)
.padding(.leading)
if viewModel.videoPlayerViewModels.count > 1 {
Menu {
ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in
Button {
viewModel.selectedVideoPlayerViewModel = viewModelOption
} label: {
if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName {
Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(viewModelOption.versionName ?? L10n.noTitle)
}
}
}
} label: {
HStack(spacing: 5) {
Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle)
.fontWeight(.semibold)
.fixedSize()
Image(systemName: "chevron.down")
}
}
.padding(.leading)
}
}
}
}

View File

@ -81,6 +81,29 @@ struct PortraitHeaderOverlayView: View {
.stroke(Color.secondary, lineWidth: 1))
}
}
if viewModel.videoPlayerViewModels.count > 1 {
Menu {
ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in
Button {
viewModel.selectedVideoPlayerViewModel = viewModelOption
} label: {
if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName {
Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(viewModelOption.versionName ?? L10n.noTitle)
}
}
}
} label: {
HStack(spacing: 5) {
Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle)
.fontWeight(.semibold)
.fixedSize()
Image(systemName: "chevron.down")
}
}
}
}
.padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30)
}
@ -90,8 +113,8 @@ struct PortraitHeaderOverlayView: View {
// MARK: Play
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
@ -109,13 +132,13 @@ struct PortraitHeaderOverlayView: View {
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
.cornerRadius(10)
}
.disabled(viewModel.playButtonItem == nil)
.disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil)
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}

View File

@ -395,7 +395,10 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider {
overlayType: .compact,
shouldShowPlayPreviousItem: true,
shouldShowPlayNextItem: true,
shouldShowAutoPlay: true)
shouldShowAutoPlay: true,
container: "",
filename: nil,
versionName: nil)
static var previews: some View {
ZStack {

View File

@ -343,6 +343,13 @@ extension VLCPlayerViewController {
}
viewModel = newViewModel
switch viewModel.streamType {
case .transcode:
LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")")
case .direct:
LogManager.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")")
}
}
// MARK: startPlayback