diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 338ddd85..2284fbd1 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -23,7 +23,7 @@ protocol PlayerOverlayDelegate { func didGenerallyTap() func didBeginScrubbing() - func didEndScrubbing(position: Double) + func didEndScrubbing() func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) diff --git a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift index 4cfc520c..a01b1f87 100644 --- a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -21,7 +21,7 @@ struct ItemLandscapeMainView: View { // MARK: Sidebar Image VStack { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130), + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130), bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 130, height: 195) .cornerRadius(10) @@ -44,7 +44,8 @@ struct ItemLandscapeMainView: View { .frame(width: 130, height: 40) .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) .cornerRadius(10) - }.disabled(viewModel.playButtonItem == nil) + } + .disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil) Spacer() } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 8f198282..0d039e93 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -117,6 +117,8 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Image(systemName: "captions.bubble") } } + .disabled(viewModel.selectedSubtitleStreamIndex == -1) + .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) } // MARK: Settings Menu diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index 4b4fce33..e30045ab 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -23,7 +23,7 @@ protocol PlayerOverlayDelegate { func didGenerallyTap() func didBeginScrubbing() - func didEndScrubbing(position: Double) + func didEndScrubbing() func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 33b7ee5d..575fd84e 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -27,7 +27,7 @@ class VLCPlayerViewController: UIViewController { private var vlcMediaPlayer = VLCMediaPlayer() private var lastPlayerTicks: Int64 = 0 private var lastProgressReportTicks: Int64 = 0 - private var cancellables = Set() + private var viewModelReactCancellables = Set() private var overlayDismissTimer: Timer? private var currentPlayerTicks: Int64 { @@ -229,17 +229,13 @@ extension VLCPlayerViewController { stopOverlayDismissTimer() - // UX improvement - (vlcMediaPlayer.drawable as! UIView).isHidden = true - // Stop current media if there is one if vlcMediaPlayer.media != nil { - cancellables.forEach({ $0.cancel() }) + viewModelReactCancellables.forEach({ $0.cancel() }) vlcMediaPlayer.stop() viewModel.sendStopReport() viewModel.playerOverlayDelegate = nil - vlcMediaPlayer.media = nil } lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 @@ -257,32 +253,27 @@ extension VLCPlayerViewController { newViewModel.getAdjacentEpisodes() newViewModel.playerOverlayDelegate = self + let startPercentage = viewModel.item.userData?.playedPercentage ?? 0 + + if startPercentage > 0 { + newViewModel.sliderPercentage = startPercentage / 100 + } + + didSelectSubtitleStream(index: newViewModel.selectedSubtitleStreamIndex) + didSelectAudioStream(index: newViewModel.selectedAudioStreamIndex) + viewModel = newViewModel } + // MARK: startPlayback func startPlayback() { - // UX improvement - (vlcMediaPlayer.drawable as! UIView).isHidden = false - vlcMediaPlayer.play() + setMediaPlayerTimeAtCurrentSlider() + viewModel.sendPlayReport() restartOverlayDismissTimer() - - // 1 second = 10,000,000 ticks - let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 - - if startTicks != 0 { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let secondsScrubbedTo = startTicks / 10_000_000 - let offset = secondsScrubbedTo - Int64(videoPosition) - if offset > 0 { - vlcMediaPlayer.jumpForward(Int32(offset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(offset))) - } - } } // MARK: setupViewModelListeners @@ -290,27 +281,42 @@ extension VLCPlayerViewController { private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { viewModel.$playbackSpeed.sink { newSpeed in self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$screenFilled.sink { shouldFill in self.changeFill(to: shouldFill) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { self.didBeginScrubbing() } else { - self.didEndScrubbing(position: self.viewModel.sliderPercentage) + self.didEndScrubbing() } - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.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))) + } } } @@ -386,13 +392,15 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { - viewModel.playerState = VLCMediaPlayerState.playing } lastPlayerTicks = currentPlayerTicks + // Send progress report every 5 seconds if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { viewModel.sendProgressReport() @@ -438,7 +446,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled if viewModel.subtitlesEnabled { - vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32 + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 } @@ -501,19 +509,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { stopOverlayDismissTimer() } - func didEndScrubbing(position: Double) { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(viewModel.item.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))) - } + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() restartOverlayDismissTimer() diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index b5b3a969..7adbffb0 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -79,9 +79,18 @@ extension BaseItemDto { hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") } + var subtitle: String? = nil + + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } + let videoPlayerViewModel = VideoPlayerViewModel(item: self, title: self.name!, - subtitle: self.seriesName, + subtitle: subtitle, streamURL: streamURL.url!, hlsURL: hlsURL.url!, response: response, @@ -92,7 +101,7 @@ extension BaseItemDto { playerState: .playing, shouldShowGoogleCast: false, shouldShowAirplay: false, - subtitlesEnabled: defaultAudioStream?.index != nil, + subtitlesEnabled: defaultSubtitleStream?.index != nil, sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 06f858ad..d6c70b5e 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -30,7 +30,7 @@ extension Defaults.Keys { static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .thirty, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .thirty, suite: SwiftfinStore.Defaults.suite) + static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) + static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) static let nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 7fcecd21..6f0080ec 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -35,8 +35,18 @@ final class VideoPlayerViewModel: ViewModel { } } @Published var sliderIsScrubbing: Bool = false - @Published var selectedAudioStreamIndex: Int - @Published var selectedSubtitleStreamIndex: Int + @Published var selectedAudioStreamIndex: Int { + didSet { + previousItemVideoPlayerViewModel?.matchAudioStream(with: self) + nextItemVideoPlayerViewModel?.matchAudioStream(with: self) + } + } + @Published var selectedSubtitleStreamIndex: Int { + didSet { + previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + } + } @Published var showAdjacentItems: Bool @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? @@ -173,6 +183,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.nextItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -184,6 +197,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.previousItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -198,6 +214,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.previousItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -206,6 +225,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.nextItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -213,6 +235,25 @@ extension VideoPlayerViewModel { }) .store(in: &cancellables) } + + private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentSubtitleStream = masterViewModel.subtitleStreams.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }) else { return } + guard let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, currentSubtitleStream) }) else { return } + + self.subtitlesEnabled = masterViewModel.subtitlesEnabled + self.selectedSubtitleStreamIndex = matchingSubtitleStream.index ?? -1 + } + + private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), + let matchingAudioStream = self.audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } + + self.selectedAudioStreamIndex = matchingAudioStream.index ?? -1 + } + + private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { + return lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language + } } // MARK: Reports