From 59465a3c4ad971176712f2f1e81371a94141d083 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 07:21:44 -0700 Subject: [PATCH] Initial implementation over --- .../Components/MediaPlayButtonRowView.swift | 9 +- .../Views/ItemView/EpisodeItemView.swift | 10 +- .../Views/LatestMediaView.swift | 2 +- .../Views/VideoPlayer/AudioView.swift | 66 - .../InfoTabBarViewController.swift | 59 - .../Views/VideoPlayer/MediaInfoView.swift | 121 -- .../NativePlayerViewController.swift | 159 +++ .../VideoPlayer/PlayerOverlayDelegate.swift | 30 + .../Views/VideoPlayer/SubtitlesView.swift | 67 - .../Views/VideoPlayer/VideoPlayer.storyboard | 126 -- .../Views/VideoPlayer/VideoPlayer.swift | 28 - .../Views/VideoPlayer/VideoPlayerView.swift | 25 + .../VideoPlayerViewController.swift | 772 ----------- JellyfinPlayer.xcodeproj/project.pbxproj | 142 +- JellyfinPlayer/App/AppDelegate.swift | 8 + JellyfinPlayer/Views/ItemView/ItemView.swift | 18 +- .../Landscape/ItemLandscapeMainView.swift | 14 +- .../ItemPortraitHeaderOverlayView.swift | 7 +- .../Portrait/ItemPortraitMainView.swift | 7 +- JellyfinPlayer/Views/SettingsView.swift | 2 + .../NativePlayerViewController.swift | 121 ++ .../Views/VideoPlayer/PlaybackSpeed.swift | 40 + .../VideoPlayer/PlayerOverlayDelegate.swift | 30 + .../VLCPlayerCompactOverlayView.swift | 271 ++++ .../VideoPlayer/VLCPlayerOverlayView.swift | 258 ++++ .../VideoPlayer/VLCPlayerViewController.swift | 445 ++++++ .../Views/VideoPlayer/VideoPlayer.storyboard | 252 ---- .../Views/VideoPlayer/VideoPlayer.swift | 1188 ----------------- .../VideoPlayerCastDeviceSelector.swift | 93 -- .../VideoPlayer/VideoPlayerSettingsView.swift | 89 -- .../Views/VideoPlayer/VideoPlayerView.swift | 41 + .../Views/VideoPlayer/VideoUpNextView.swift | 54 - Shared/Coordinators/ItemCoordinator.swift | 4 +- .../Coordinators/VideoPlayerCoordinator.swift | 30 - .../iOSVideoPlayerCoordinator.swift | 42 + .../tvOSVideoPlayerCoordinator.swift | 35 + .../Extensions/URLComponentsExtensions.swift | 22 + .../SwiftfinStore/SwiftfinStoreDefaults.swift | 1 + Shared/ViewModels/HomeViewModel.swift | 4 +- Shared/ViewModels/ItemViewModel.swift | 103 ++ Shared/ViewModels/LibraryViewModel.swift | 4 +- Shared/ViewModels/VideoPlayerViewModel.swift | 218 +++ 42 files changed, 1960 insertions(+), 3057 deletions(-) delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift delete mode 100644 Shared/Coordinators/VideoPlayerCoordinator.swift create mode 100644 Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift create mode 100644 Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift create mode 100644 Shared/Extensions/URLComponentsExtensions.swift create mode 100644 Shared/ViewModels/VideoPlayerViewModel.swift diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 4e6ef971..4063152a 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -11,15 +11,22 @@ import SwiftUI struct MediaPlayButtonRowView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: ItemViewModel @State var wrappedScrollView: UIScrollView? var body: some View { HStack { VStack { - NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) +// } + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) } + Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) .font(.caption) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift index d0632bf8..8e3c3d51 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift @@ -11,6 +11,8 @@ import SwiftUI import JellyfinAPI struct EpisodeItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: EpisodeItemViewModel @State var actors: [BaseItemPerson] = [] @@ -130,7 +132,13 @@ struct EpisodeItemView: View { .font(.caption) } VStack { - NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// MediaViewActionButton(icon: "play.fill") +// } + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { +// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) MediaViewActionButton(icon: "play.fill") } Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) diff --git a/JellyfinPlayer tvOS/Views/LatestMediaView.swift b/JellyfinPlayer tvOS/Views/LatestMediaView.swift index 92be14d8..0bc78790 100644 --- a/JellyfinPlayer tvOS/Views/LatestMediaView.swift +++ b/JellyfinPlayer tvOS/Views/LatestMediaView.swift @@ -27,7 +27,7 @@ struct LatestMediaView: View { viewDidLoad = true DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12) + UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { response in diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift deleted file mode 100644 index 9b198f94..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// - /* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -class AudioViewController: InfoTabViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Audio" - - } - - func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - } - -} - -struct AudioView: View { - - @State var selectedTrack: Int32 = -1 - @State var audioTrackArray: [AudioTrack] = [] - - weak var delegate: VideoPlayerSettingsDelegate? - - var body : some View { - NavigationView { - VStack { - List(audioTrackArray, id: \.id) { track in - Button(action: { - delegate?.selectNew(audioTrack: track.id) - selectedTrack = track.id - }, label: { - HStack(spacing: 10) { - if track.id == selectedTrack { - Image(systemName: "checkmark") - } else { - Image(systemName: "checkmark") - .hidden() - } - Text(track.name) - } - }) - - } - } - .frame(width: 400) - .frame(maxHeight: 400) - } - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift deleted file mode 100644 index e7f13ebd..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import TVUIKit -import JellyfinAPI - -class InfoTabViewController: UIViewController { - var height: CGFloat = 420 -} - -class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate { - - var videoPlayer: VideoPlayerViewController? - var subtitleViewController: SubtitlesViewController? - var audioViewController: AudioViewController? - var mediaInfoController: MediaInfoViewController? - - override func viewDidLoad() { - super.viewDidLoad() - mediaInfoController = MediaInfoViewController() - audioViewController = AudioViewController() - subtitleViewController = SubtitlesViewController() - - viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!] - - } - - func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - - mediaInfoController?.setMedia(item: mediaItem) - - audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate) - - subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate) - - } - - override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - - if let index = tabBar.items?.firstIndex(of: item), - let tabViewController = viewControllers?[index] as? InfoTabViewController, - let width = videoPlayer?.infoPanelContainerView.frame.width { - let height = tabViewController.height + tabBar.frame.size.height - UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in - videoPlayer?.infoPanelContainerView.frame = CGRect(x: 88, y: 87, width: width, height: height) - } - } - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift deleted file mode 100644 index e914b3ad..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI -import JellyfinAPI - -class MediaInfoViewController: InfoTabViewController { - private var contentView: UIHostingController! - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Info" - } - - func setMedia(item: BaseItemDto) { - contentView = UIHostingController(rootView: MediaInfoView(item: item)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - height = self.view.frame.height - - } -} - -struct MediaInfoView: View { - @State var item: BaseItemDto? - - var body: some View { - if let item = item { - HStack(spacing: 30) { - VStack { - ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) - .frame(width: 200, height: 300) - .cornerRadius(10) - .ignoresSafeArea() - Spacer() - } - - VStack(alignment: .leading, spacing: 10) { - if item.type == "Episode" { - Text(item.seriesName ?? "Series") - .fontWeight(.bold) - - HStack { - Text(item.name ?? "Episode") - .foregroundColor(.secondary) - - Text(item.getEpisodeLocator() ?? "") - - if let date = item.premiereDate { - Text(formatDate(date: date)) - } - } - } else { - Text(item.name ?? "Movie") - .fontWeight(.bold) - } - - HStack(spacing: 10) { - if item.type != "Episode" { - if let year = item.productionYear { - Text(String(year)) - } - } - - if item.runTimeTicks != nil { - Text("•") - Text(item.getItemRuntime()) - } - - if let rating = item.officialRating { - Text("•") - - Text("\(rating)").font(.subheadline) - .fontWeight(.semibold) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - - } - } - .foregroundColor(.secondary) - - if let overview = item.overview { - Text(overview) - .padding(.top) - .foregroundColor(.secondary) - } - - Spacer() - } - - Spacer() - - } - .padding(.leading, 350) - .padding(.trailing, 125) - } else { - EmptyView() - } - - } - - func formatDate(date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d, yyyy" - - return formatter.string(from: date) - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift new file mode 100644 index 00000000..f5fcbbc3 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift @@ -0,0 +1,159 @@ +// +// NativePlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/20/21. +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class NativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + private var cancellables = Set() + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player = AVPlayer(url: viewModel.hlsURL) + + player.appliesMediaSelectionCriteriaAutomatically = false + player.currentItem?.externalMetadata = createMetadata() + player.currentItem?.navigationMarkerGroups = createNavigationMarkerGroups() + +// let chevron = UIImage(systemName: "chevron.right.circle.fill")! +// let testAction = UIAction(title: "Next", image: chevron) { action in +// SessionAPI.sendSystemCommand(sessionId: viewModel.response.playSessionId!, command: .setSubtitleStreamIndex) +// .sink { completion in +// print(completion) +// } receiveValue: { _ in +// print("idk but we're here") +// } +// .store(in: &self.cancellables) +// } + +// self.transportBarCustomMenuItems = [testAction] + +// self.infoViewActions.append(testAction) + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in +// print("Timer timed: \(time)") + + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any] = [ + .commonIdentifierTitle: viewModel.title, + .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", + .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any, + .commonIdentifierDescription: viewModel.item.overview ?? "", + .iTunesMetadataContentRating: viewModel.item.officialRating ?? "", + .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "" + ] + + return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) } + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + private func createNavigationMarkerGroups() -> [AVNavigationMarkersGroup] { + guard let chapters = viewModel.item.chapters else { return [] } + + var metadataGroups: [AVTimedMetadataGroup] = [] + + // TODO: Determine range between chapters + chapters.forEach { chapterInfo in + var chapterMetadata: [AVMetadataItem] = [] + + let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapterInfo.name ?? "No Name") + chapterMetadata.append(titleItem) + + let imageItem = createMetadataItem(for: .commonIdentifierArtwork, value: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any) + chapterMetadata.append(imageItem) + + let startTime = CMTimeMake(value: chapterInfo.startPositionTicks ?? 0, timescale: 10_000_000) + let endTime = CMTimeMake(value: (chapterInfo.startPositionTicks ?? 0) + 50_000_000, timescale: 10_000_000) + let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime) + + metadataGroups.append(AVTimedMetadataGroup(items: chapterMetadata, timeRange: timeRange)) + } + + return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + } + + private func sendProgressReport(seconds: Double) { + viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + } + + private func stop() { + self.player?.pause() + viewModel.sendStopReport(ticks: 10_000_000) + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift new file mode 100644 index 00000000..338ddd85 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -0,0 +1,30 @@ +// +// PlayerOverlayDelegate.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +protocol PlayerOverlayDelegate { + + func didSelectClose() + func didSelectGoogleCast() + func didSelectAirplay() + func didSelectCaptions() + func didSelectMenu() + func didDeselectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing(position: Double) + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift deleted file mode 100644 index a4e35717..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -class SubtitlesViewController: InfoTabViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Subtitles" - - } - - func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - } -} - -struct SubtitleView: View { - - @State var selectedTrack: Int32 = -1 - @State var subtitleTrackArray: [Subtitle] = [] - - weak var delegate: VideoPlayerSettingsDelegate? - - var body : some View { - NavigationView { - VStack { - List(subtitleTrackArray, id: \.id) { track in - Button(action: { - delegate?.selectNew(subtitleTrack: track.id) - selectedTrack = track.id - }, label: { - HStack(spacing: 10) { - if track.id == selectedTrack { - Image(systemName: "checkmark") - } else { - Image(systemName: "checkmark") - .hidden() - } - Text(track.name) - } - }) - - } - } - .frame(width: 400) - .frame(maxHeight: 400) - - } - } - -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard deleted file mode 100644 index 1eac1fbd..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 809ad75a..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI -import JellyfinAPI - -struct VideoPlayerView: UIViewControllerRepresentable { - var item: BaseItemDto - - func makeUIViewController(context: Context) -> some UIViewController { - - let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) - let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController - viewController.manifest = item - - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift new file mode 100644 index 00000000..0b2aee90 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -0,0 +1,25 @@ +// +// VideoPlayerView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import UIKit +import SwiftUI + +struct NativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = NativePlayerViewController + + func makeUIViewController(context: Context) -> NativePlayerViewController { + + return NativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift deleted file mode 100644 index 3e20b5ef..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift +++ /dev/null @@ -1,772 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import TVUIKit -import TVVLCKit -import MediaPlayer -import JellyfinAPI -import Combine -import Defaults - -protocol VideoPlayerSettingsDelegate: AnyObject { - func selectNew(audioTrack id: Int32) - func selectNew(subtitleTrack id: Int32) -} - -class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate { - - @IBOutlet weak var videoContentView: UIView! - @IBOutlet weak var controlsView: UIView! - - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - - @IBOutlet weak var transportBarView: UIView! - @IBOutlet weak var scrubberView: UIView! - @IBOutlet weak var scrubLabel: UILabel! - @IBOutlet weak var gradientView: UIView! - - @IBOutlet weak var currentTimeLabel: UILabel! - @IBOutlet weak var remainingTimeLabel: UILabel! - - @IBOutlet weak var infoPanelContainerView: UIView! - - var infoTabBarViewController: InfoTabBarViewController? - var focusedOnTabBar: Bool = false - var showingInfoPanel: Bool = false - - var mediaPlayer = VLCMediaPlayer() - - var lastProgressReportTime: Double = 0 - var lastTime: Float = 0.0 - var startTime: Int = 0 - - var selectedAudioTrack: Int32 = -1 - var selectedCaptionTrack: Int32 = -1 - - var subtitleTrackArray: [Subtitle] = [] - var audioTrackArray: [AudioTrack] = [] - - var playing: Bool = false - var seeking: Bool = false - var showingControls: Bool = false - var loading: Bool = true - - var initialSeekPos: CGFloat = 0 - var videoPos: Double = 0 - var videoDuration: Double = 0 - var controlsAppearTime: Double = 0 - - var manifest: BaseItemDto = BaseItemDto() - var playbackItem = PlaybackItem() - var playSessionId: String = "" - - var cancellables = Set() - - override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { - - super.didUpdateFocus(in: context, with: coordinator) - - // Check if focused on the tab bar, allows for swipe up to dismiss the info panel - if let nextFocused = context.nextFocusedView, - nextFocused.description.contains("UITabBarButton") { - // Set value after half a second so info panel is not dismissed instantly when swiping up from content - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.focusedOnTabBar = true - } - } else { - focusedOnTabBar = false - } - - } - - override func viewDidLoad() { - super.viewDidLoad() - - activityIndicator.isHidden = false - activityIndicator.startAnimating() - - mediaPlayer.delegate = self - mediaPlayer.drawable = videoContentView - - if let runTimeTicks = manifest.runTimeTicks { - videoDuration = Double(runTimeTicks / 10_000_000) - } - - // Black gradient behind transport bar - let gradientLayer: CAGradientLayer = CAGradientLayer() - gradientLayer.frame.size = self.gradientView.frame.size - gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor] - gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0) - gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0) - self.gradientView.layer.addSublayer(gradientLayer) - - infoPanelContainerView.center = CGPoint(x: infoPanelContainerView.center.x, y: -infoPanelContainerView.frame.height) - infoPanelContainerView.layer.cornerRadius = 40 - - let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - blurEffectView.frame = infoPanelContainerView.bounds - blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - blurEffectView.layer.cornerRadius = 40 - blurEffectView.clipsToBounds = true - infoPanelContainerView.addSubview(blurEffectView) - infoPanelContainerView.sendSubviewToBack(blurEffectView) - - transportBarView.layer.cornerRadius = CGFloat(5) - - setupGestures() - - fetchVideo() - - setupNowPlayingCC() - - // Adjust subtitle size - mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) - - } - - func fetchVideo() { - // Fetch max bitrate from UserDefaults depending on current connection mode - let maxBitrate = Defaults[.inNetworkBandwidth] - - // Build a device profile - let builder = DeviceProfileBuilder() - builder.setMaxBitrate(bitrate: maxBitrate) - let profile = builder.buildProfile() - - let currentUser = SessionManager.main.currentLogin.user - - let playbackInfo = PlaybackInfoDto(userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) - - DispatchQueue.global(qos: .userInitiated).async { [self] in - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { [self] response in - - videoContentView.setNeedsLayout() - videoContentView.setNeedsDisplay() - - playSessionId = response.playSessionId ?? "" - - guard let mediaSource = response.mediaSources?.first.self else { - return - } - - let item = PlaybackItem() - let streamURL: URL - - // Item is being transcoded by request of server - if let transcodiungUrl = mediaSource.transcodingUrl { - item.videoType = .transcode - streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(transcodiungUrl)")! - } - // Item will be directly played by the client - else { - item.videoType = .directPlay -// streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")! - streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! - } - - item.videoUrl = streamURL - - let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams! { - - if stream.type == .subtitle { - var deliveryUrl: URL? - - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")! - } - - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "") - - if stream.isDefault == true { - selectedCaptionTrack = Int32(stream.index!) - } - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!)) - - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - - audioTrackArray.append(track) - } - } - - // If no default audio tracks select the first one - if selectedAudioTrack == -1 && !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray.first!.id - } - - self.sendPlayReport() - playbackItem = item - - mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) - mediaPlayer.media.delegate = self - mediaPlayer.play() - - // 1 second = 10,000,000 ticks - - if let rawStartTicks = manifest.userData?.playbackPositionTicks { - mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000)) - } - - subtitleTrackArray.forEach { sub in - if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { - mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) - } - } - - playing = true - setupInfoPanel() - - }) - .store(in: &cancellables) - - } - } - - func setupNowPlayingCC() { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.pauseCommand.isEnabled = true - - commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [15] - - commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30] - - commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.enableLanguageOptionCommand.isEnabled = true - - // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { _ in - self.pause() - self.showingControls = true - self.controlsView.isHidden = false - self.controlsAppearTime = CACurrentMediaTime() - return .success - } - - // Add handler for Play command - commandCenter.playCommand.addTarget { _ in - self.play() - self.showingControls = false - self.controlsView.isHidden = true - return .success - } - - // Add handler for FF command - commandCenter.skipForwardCommand.addTarget { _ in - self.mediaPlayer.jumpForward(30) - self.sendProgressReport(eventName: "timeupdate") - return .success - } - - // Add handler for RW command - commandCenter.skipBackwardCommand.addTarget { _ in - self.mediaPlayer.jumpBackward(15) - self.sendProgressReport(eventName: "timeupdate") - return .success - } - - // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else {return .commandFailed} - - if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { - let targetSeconds = event.positionTime - let videoPosition = Double(self.mediaPlayer.time.intValue / 1000) - let offset = targetSeconds - videoPosition - - if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset)) - } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset))) - } - self.sendProgressReport(eventName: "unpause") - - return .success - } else { - return .commandFailed - } - } - - var runTicks = 0 - var playbackTicks = 0 - - if let ticks = manifest.runTimeTicks { - runTicks = Int(ticks / 10_000_000) - } - - if let ticks = manifest.userData?.playbackPositionTicks { - playbackTicks = Int(ticks / 10_000_000) - } - - var nowPlayingInfo = [String: Any]() - - nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - if manifest.type == "Episode" { - nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "") • \(manifest.getEpisodeLocator())" - } - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks - - if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 500)) { - if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - return artworkImage - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - func updateNowPlayingCenter(time: Double?, playing: Bool?) { - - var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() - - if let playing = playing { - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0 - } - - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = mediaPlayer.time.intValue / 1000 - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } - - // Grabs a reference to the info panel view controller - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "infoView" { - infoTabBarViewController = segue.destination as? InfoTabBarViewController - infoTabBarViewController?.videoPlayer = self - - } - } - - // MARK: Player functions - // Animate the scrubber when playing state changes - func animateScrubber() { - let y: CGFloat = playing ? 0 : -20 - let height: CGFloat = playing ? 10 : 30 - - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: { - self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height) - }) - } - - func pause() { - playing = false - mediaPlayer.pause() - - self.sendProgressReport(eventName: "pause") - - self.updateNowPlayingCenter(time: nil, playing: false) - - animateScrubber() - - self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - } - - func play () { - playing = true - mediaPlayer.play() - - self.updateNowPlayingCenter(time: nil, playing: true) - - self.sendProgressReport(eventName: "unpause") - - animateScrubber() - } - - func toggleInfoContainer() { - showingInfoPanel.toggle() - - infoTabBarViewController?.view.isUserInteractionEnabled = showingInfoPanel - - if showingInfoPanel && seeking { - scrubLabel.isHidden = true - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { - self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height) - }) { _ in - self.scrubLabel.frame = CGRect(x: (self.initialSeekPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - self.scrubLabel.text = self.currentTimeLabel.text - } - seeking = false - - } - - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in - let size = infoPanelContainerView.frame.size - let y: CGFloat = showingInfoPanel ? 87 : -size.height - - infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height) - } - - } - - // MARK: Gestures - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - for item in presses { - if item.type == .select { - selectButtonTapped() - } - } - } - - func setupGestures() { - self.becomeFirstResponder() - - // vlc crap - videoContentView.gestureRecognizers?.forEach { gr in - videoContentView.removeGestureRecognizer(gr) - } - videoContentView.subviews.forEach { sv in - sv.gestureRecognizers?.forEach { gr in - sv.removeGestureRecognizer(gr) - } - } - - let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) - let playPauseType = UIPress.PressType.playPause - playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)] - view.addGestureRecognizer(playPauseGesture) - - let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:))) - let backPress = UIPress.PressType.menu - backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)] - view.addGestureRecognizer(backTapGesture) - - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - @objc func backButtonPressed(tap: UITapGestureRecognizer) { - // Dismiss info panel - if showingInfoPanel { - if focusedOnTabBar { - toggleInfoContainer() - } - return - } - - // Cancel seek and move back to initial position - if seeking { - scrubLabel.isHidden = true - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { - self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10) - }) - play() - seeking = false - } else { - // Dismiss view - self.resignFirstResponder() - mediaPlayer.stop() - sendStopReport() - self.navigationController?.popViewController(animated: true) - } - } - - @objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if loading { - return - } - - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - - // Swiped up - Handle dismissing info panel - if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) { - toggleInfoContainer() - return - } - - if showingInfoPanel { - return - } - - // Swiped down - Show the info panel - if translation.y > 200 { - toggleInfoContainer() - return - } - - // Ignore seek if video is playing - if playing { - return - } - - // Save current position if seek is cancelled and show the scrubLabel - if !seeking { - initialSeekPos = self.scrubberView.frame.minX - seeking = true - self.scrubLabel.isHidden = false - } - - let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width) - - UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: { - let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) - - self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30) - self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - self.scrubLabel.text = (self.formatSecondsToHMS(time)) - - }) - - } - - /// Play/Pause or Select is pressed on the AppleTV remote - @objc func selectButtonTapped() { - print("select") - if loading { - return - } - - showingControls = true - controlsView.isHidden = false - controlsAppearTime = CACurrentMediaTime() - - // Move to seeked position - if seeking { - scrubLabel.isHidden = true - - // Move current time to the scrubbed position - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in - - self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) - - }) - - let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) - - self.currentTimeLabel.text = self.scrubLabel.text - self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time) - - mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width) - - play() - - seeking = false - return - } - - playing ? pause() : play() - } - - // MARK: Jellyfin Playstate updates - func sendProgressReport(eventName: String) { - updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing) - - if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) - if ticks == 0 { - ticks = manifest.userData?.playbackPositionTicks ?? 0 - } - - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } - - func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: []) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback stop report sent!") - }) - .store(in: &cancellables) - } - - func sendPlayReport() { - startTime = Int(Date().timeIntervalSince1970) * 10000000 - - print("sending play report!") - - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback start report sent!") - }) - .store(in: &cancellables) - } - - // MARK: VLC Delegate - - func mediaPlayerStateChanged(_ aNotification: Notification!) { - let currentState: VLCMediaPlayerState = mediaPlayer.state - switch currentState { - case .buffering: - print("Video is buffering") - loading = true - activityIndicator.isHidden = false - activityIndicator.startAnimating() - mediaPlayer.pause() - usleep(10000) - mediaPlayer.play() - break - case .stopped: - print("stopped") - - break - case .ended: - print("ended") - - break - case .opening: - print("opening") - - break - case .paused: - print("paused") - - break - case .playing: - print("Video is playing") - loading = false - sendProgressReport(eventName: "unpause") - DispatchQueue.main.async { [self] in - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - playing = true - break - case .error: - print("error") - break - case .esAdded: - print("esAdded") - break - default: - print("default") - break - - } - - } - - // Move time along transport bar - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - - if loading { - loading = false - DispatchQueue.main.async { [self] in - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - updateNowPlayingCenter(time: nil, playing: true) - } - - let time = mediaPlayer.position - if time != lastTime { - self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000)) - self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000))) - - self.videoPos = Double(mediaPlayer.position) - - let newPos = videoPos * Double(self.transportBarView.frame.width) - if !newPos.isNaN && self.playing { - self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10) - self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) - } - - if showingControls { - if CACurrentMediaTime() - controlsAppearTime > 5 { - showingControls = false - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { - self.controlsView.alpha = 0.0 - }, completion: { (_: Bool) in - self.controlsView.isHidden = true - self.controlsView.alpha = 1 - }) - controlsAppearTime = 999_999_999_999_999 - } - } - - } - - lastTime = time - - if CACurrentMediaTime() - lastProgressReportTime > 5 { - sendProgressReport(eventName: "timeupdate") - lastProgressReportTime = CACurrentMediaTime() - } - } - - // MARK: Settings Delegate - func selectNew(audioTrack id: Int32) { - selectedAudioTrack = id - mediaPlayer.currentAudioTrackIndex = id - } - - func selectNew(subtitleTrack id: Int32) { - selectedCaptionTrack = id - mediaPlayer.currentVideoSubTitleIndex = id - } - - func setupInfoPanel() { - infoTabBarViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self) - } - - func formatSecondsToHMS(_ seconds: Double) -> String { - let timeHMSFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .positional - formatter.allowedUnits = seconds >= 3600 ? - [.hour, .minute, .second] : - [.minute, .second] - formatter.zeroFormattingBehavior = .pad - return formatter - }() - - guard !seconds.isNaN, - let text = timeHMSFormatter.string(from: seconds) else { - return "00:00" - } - - return text.hasPrefix("0") && text.count > 4 ? - .init(text.dropFirst()) : text - } -} - -extension Comparable { - func clamped(to limits: ClosedRange) -> Self { - return min(max(self, limits.lowerBound), limits.upperBound) - } -} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 76dc947c..09d8d485 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -14,15 +14,7 @@ 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; - 0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */; }; 363CADF08820D3B2055CF1D8 /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */; }; - 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */; }; - 531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069512684E7EE00CFFDBA /* MediaInfoView.swift */; }; - 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069522684E7EE00CFFDBA /* SubtitlesView.swift */; }; - 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069532684E7EE00CFFDBA /* VideoPlayer.swift */; }; - 5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069542684E7EE00CFFDBA /* AudioView.swift */; }; - 5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */; }; - 5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */; }; 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A16268B919A003024C9 /* SeriesItemView.swift */; }; 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A18268B947A003024C9 /* PlainLinkButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; @@ -39,8 +31,6 @@ 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */; }; 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */; }; 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272538268C20100035FBF1 /* EpisodeItemView.swift */; }; - 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */; }; - 53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; }; 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; @@ -64,7 +54,6 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; - 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; }; 53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAC269CFAEA00A2D8B7 /* Puppy */; }; 53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAE269CFAF600A2D8B7 /* Puppy */; }; 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; @@ -150,7 +139,6 @@ 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; - 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */; }; 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */; }; 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; 560CA59B3956A4CA13EDAC05 /* Pods_JellyfinPlayer_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86BAC42C3764D232C8DF8F5E /* Pods_JellyfinPlayer_iOS.framework */; }; @@ -165,7 +153,7 @@ 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; - 6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; + 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; }; 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; @@ -317,7 +305,6 @@ E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; - E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */; }; E193D547271941C500900D82 /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* UserListView.swift */; }; E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; }; @@ -340,6 +327,21 @@ E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; }; E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; }; + E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; + E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; + E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; + E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */; }; + E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */; }; + E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */; }; + E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */; }; + E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; + E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; }; + E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C7277AE40900918266 /* NativePlayerViewController.swift */; }; + E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C8277AE40900918266 /* VideoPlayerView.swift */; }; + E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; + E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; + E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; }; + E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; @@ -402,18 +404,10 @@ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 091B5A882683142E00D78B61 /* UDPBroadCastConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UDPBroadCastConnection.swift; sourceTree = ""; }; 09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = ""; }; - 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUpNextView.swift; sourceTree = ""; }; 14E199C7BBA98782CAD2F0D4 /* Pods-JellyfinPlayer iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.release.xcconfig"; sourceTree = ""; }; 20CA36DDD247EED8D16438A5 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; 4BDCEE3B49CF70A9E9BA3CD8 /* Pods-WidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetExtension/Pods-WidgetExtension.debug.xcconfig"; sourceTree = ""; }; 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = ""; }; - 531069512684E7EE00CFFDBA /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = ""; }; - 531069522684E7EE00CFFDBA /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = ""; }; - 531069532684E7EE00CFFDBA /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; - 531069542684E7EE00CFFDBA /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = ""; }; - 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; - 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = ""; }; 53116A16268B919A003024C9 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; 53116A18268B947A003024C9 /* PlainLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainLinkButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -427,8 +421,6 @@ 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewActionButton.swift; sourceTree = ""; }; 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = ""; }; 53272538268C20100035FBF1 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; - 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCastDeviceSelector.swift; sourceTree = ""; }; - 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; 534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; @@ -440,7 +432,6 @@ 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; - 535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = ""; }; 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; 5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; @@ -501,7 +492,6 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; - 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemView.swift; sourceTree = ""; }; 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; 59AFF849629F3C787909A911 /* Pods_WidgetExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -514,7 +504,7 @@ 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = ""; }; 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; - 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; + 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = ""; }; 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = ""; }; 624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = ""; }; @@ -610,6 +600,19 @@ E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = ""; }; E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = ""; }; + E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; + E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; + E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; + E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = ""; }; + E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerCompactOverlayView.swift; sourceTree = ""; }; + E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = ""; }; + E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; + E1C812C7277AE40900918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsViewModel.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; @@ -695,13 +698,9 @@ 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( - 531069512684E7EE00CFFDBA /* MediaInfoView.swift */, - 531069522684E7EE00CFFDBA /* SubtitlesView.swift */, - 531069542684E7EE00CFFDBA /* AudioView.swift */, - 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */, - 531069532684E7EE00CFFDBA /* VideoPlayer.swift */, - 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */, - 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */, + E1C812C7277AE40900918266 /* NativePlayerViewController.swift */, + E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, + E1C812C8277AE40900918266 /* VideoPlayerView.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -720,17 +719,18 @@ 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */, 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, - C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, - C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, + C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */, 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, 09389CC626819B4500AE350E /* VideoPlayerModel.swift */, + E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); path = ViewModels; @@ -1073,6 +1073,7 @@ E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, 621338922660107500A81A2A /* StringExtensions.swift */, E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */, + E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */, 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); path = Extensions; @@ -1116,7 +1117,7 @@ C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */, E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, - 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */, + E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */, ); path = Coordinators; sourceTree = ""; @@ -1284,11 +1285,13 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( - 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */, - 535BAEA4264A151C005FA86D /* VideoPlayer.swift */, - 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */, - 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */, - 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */, + E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */, + E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, + E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, + E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */, + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, + E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1331,6 +1334,15 @@ path = Views; sourceTree = ""; }; + E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { + isa = PBXGroup; + children = ( + 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, + E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, + ); + path = VideoPlayerCoordinator; + sourceTree = ""; + }; E1DD1127271E7D15005BE12F /* Objects */ = { isa = PBXGroup; children = ( @@ -1531,7 +1543,6 @@ 53913C0226D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1426D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF926D323FE00EB3286 /* Localizable.strings in Resources */, - 5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */, 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF326D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF626D323FE00EB3286 /* Localizable.strings in Resources */, @@ -1562,7 +1573,6 @@ 534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF226D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF526D323FE00EB3286 /* Localizable.strings in Resources */, - 53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */, 53913C0426D323FE00EB3286 /* Localizable.strings in Resources */, 53913BFE26D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0D26D323FE00EB3286 /* Localizable.strings in Resources */, @@ -1762,13 +1772,11 @@ buildActionMask = 2147483647; files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, - 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, - 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */, C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */, @@ -1776,15 +1784,16 @@ 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, - E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */, 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, @@ -1804,7 +1813,6 @@ 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, - 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, @@ -1823,13 +1831,13 @@ E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, - 5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */, 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, @@ -1844,24 +1852,25 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, E19169CF272514760085832A /* HTTPScheme.swift in Sources */, + E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */, C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, - 531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */, E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, + E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, + E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */, E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, - 5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */, C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, @@ -1896,6 +1905,7 @@ E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, + E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, @@ -1906,6 +1916,7 @@ E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, + E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, @@ -1918,36 +1929,37 @@ E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */, - 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, - 0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, + E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4BE0766271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, - 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, - 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, + E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, + E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, + E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, @@ -1960,14 +1972,16 @@ E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, - 6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */, + 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, + E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, + E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */, @@ -2379,7 +2393,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2391,7 +2405,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2416,7 +2430,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2428,7 +2442,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2447,7 +2461,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2456,7 +2470,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -2474,7 +2488,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2483,7 +2497,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/JellyfinPlayer/App/AppDelegate.swift b/JellyfinPlayer/App/AppDelegate.swift index 21701dd2..6f120c94 100644 --- a/JellyfinPlayer/App/AppDelegate.swift +++ b/JellyfinPlayer/App/AppDelegate.swift @@ -7,6 +7,7 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import AVFAudio import SwiftUI import UIKit @@ -17,6 +18,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Lazily initialize datastack _ = SwiftfinStore.dataStack + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback) + } catch { + print("setting category AVAudioSessionCategoryPlayback failed") + } return true } diff --git a/JellyfinPlayer/Views/ItemView/ItemView.swift b/JellyfinPlayer/Views/ItemView/ItemView.swift index 95d37413..e95dbc72 100644 --- a/JellyfinPlayer/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer/Views/ItemView/ItemView.swift @@ -9,11 +9,6 @@ import Introspect import JellyfinAPI import SwiftUI -class VideoPlayerItem: ObservableObject { - @Published var shouldShowPlayer: Bool = false - @Published var itemToPlay = BaseItemDto() -} - // Intermediary view for ItemView to set navigation bar settings struct ItemNavigationView: View { private let item: BaseItemDto @@ -31,10 +26,7 @@ struct ItemNavigationView: View { private struct ItemView: View { @EnvironmentObject var itemRouter: ItemCoordinator.Router - @State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view. - @State private var viewDidLoad: Bool = false @State private var orientation: UIDeviceOrientation = .unknown - @StateObject private var videoPlayerItem = VideoPlayerItem() @Environment(\.horizontalSizeClass) private var hSizeClass @Environment(\.verticalSizeClass) private var vSizeClass @@ -91,19 +83,13 @@ private struct ItemView: View { var body: some View { Group { if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) + ItemPortraitMainView() .environmentObject(viewModel) } else { - ItemLandscapeMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) + ItemLandscapeMainView() .environmentObject(viewModel) } } - .onReceive(videoPlayerItem.$shouldShowPlayer) { flag in - guard flag else { return } - self.itemRouter.route(to: \.videoPlayer, viewModel.item) - } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { toolbarItemContent diff --git a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift index 49d3a83e..4cfc520c 100644 --- a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -12,13 +12,7 @@ import SwiftUI struct ItemLandscapeMainView: View { @EnvironmentObject var itemRouter: ItemCoordinator.Router - @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - init(videoIsLoading: Binding) { - self._videoIsLoading = videoIsLoading - } // MARK: innerBody @@ -34,14 +28,10 @@ struct ItemLandscapeMainView: View { Spacer().frame(height: 15) + // MARK: Play Button { - if let playButtonItem = viewModel.playButtonItem { - self.videoPlayerItem.itemToPlay = playButtonItem - self.videoPlayerItem.shouldShowPlayer = true - } + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { - // MARK: Play - HStack { Image(systemName: "play.fill") .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) diff --git a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index 99a069d5..98053a5a 100644 --- a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -12,8 +12,8 @@ import JellyfinAPI struct PortraitHeaderOverlayView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem var body: some View { VStack(alignment: .leading) { @@ -75,10 +75,7 @@ struct PortraitHeaderOverlayView: View { // MARK: Play Button { - if let playButtonItem = viewModel.playButtonItem { - self.videoPlayerItem.itemToPlay = playButtonItem - self.videoPlayerItem.shouldShowPlayer = true - } + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { HStack { Image(systemName: "play.fill") diff --git a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift index 259af534..6e588c02 100644 --- a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift @@ -11,14 +11,9 @@ import JellyfinAPI import SwiftUI struct ItemPortraitMainView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router - @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - init(videoIsLoading: Binding) { - self._videoIsLoading = videoIsLoading - } // MARK: portraitHeaderView diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index ef21dc0f..f5f405d4 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @Default(.appAppearance) var appAppearance @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength + @Default(.nativeVideoPlayer) var nativeVideoPlayer var body: some View { Form { @@ -83,6 +84,7 @@ struct SettingsView: View { } Section(header: Text("Playback")) { + Toggle("Native Player", isOn: $nativeVideoPlayer) Picker("Default local quality", selection: $inNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in Text(bitrate.name).tag(bitrate.value) diff --git a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift new file mode 100644 index 00000000..9692626d --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift @@ -0,0 +1,121 @@ +// +// NativePlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/20/21. +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class NativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player = AVPlayer(url: viewModel.hlsURL) + + player.appliesMediaSelectionCriteriaAutomatically = false + player.currentItem?.externalMetadata = createMetadata() + + let chevron = UIImage(systemName: "chevron.right.circle.fill")! + let testAction = UIAction(title: "Next", image: chevron) { action in + print("next item selected") + } + + // tvos +// self.transportBarCustomMenuItems = [testAction] + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in +// print("Timer timed: \(time)") + + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any] = [ + .commonIdentifierTitle: viewModel.title, + .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", + .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any, + .commonIdentifierDescription: viewModel.item.overview ?? "" + ] + + return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) } + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + } + + private func sendProgressReport(seconds: Double) { + viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + } + + private func stop() { + viewModel.sendStopReport(ticks: 10_000_000) + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift new file mode 100644 index 00000000..78b410f1 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift @@ -0,0 +1,40 @@ +// +// PlaybackSpeed.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +enum PlaybackSpeed: Double, CaseIterable { + case quarter = 0.25 + case half = 0.5 + case threeQuarter = 0.75 + case one = 1.0 + case oneQuarter = 1.25 + case oneHalf = 1.5 + case oneThreeQuarter = 1.75 + case two = 2.0 + + var displayTitle: String { + switch self { + case .quarter: + return "0.25x" + case .half: + return "0.5x" + case .threeQuarter: + return "0.75x" + case .one: + return "1x" + case .oneQuarter: + return "1.25x" + case .oneHalf: + return "1.5x" + case .oneThreeQuarter: + return "1.75x" + case .two: + return "2x" + } + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift new file mode 100644 index 00000000..338ddd85 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -0,0 +1,30 @@ +// +// PlayerOverlayDelegate.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +protocol PlayerOverlayDelegate { + + func didSelectClose() + func didSelectGoogleCast() + func didSelectAirplay() + func didSelectCaptions() + func didSelectMenu() + func didDeselectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing(position: Double) + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift new file mode 100644 index 00000000..67dceb15 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift @@ -0,0 +1,271 @@ +// +// VLCPlayerCompactOverlayView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/26/21. +// + +import Combine +import MobileVLCKit +import SwiftUI +import JellyfinAPI + +struct VLCPlayerCompactOverlayView: View { + + @ObservedObject var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 28, weight: .heavy, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 28, weight: .heavy, design: .default)) + default: + ProgressView() + } + } + + @ViewBuilder + private var mainBody: some View { + VStack { + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + + // MARK: Top Bar + HStack(alignment: .top) { + + VStack(alignment: .leading) { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 28, weight: .regular, design: .default)) + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } + } + + Spacer() + + HStack(spacing: 20) { + + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + + Button { + viewModel.screenFilled = !viewModel.screenFilled + } label: { + if viewModel.screenFilled { + Image(systemName: "rectangle.arrowtriangle.2.inward") + .rotationEffect(Angle(degrees: 90)) + } else { + Image(systemName: "rectangle.arrowtriangle.2.outward") + .rotationEffect(Angle(degrees: 90)) + } + } + + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.captionsEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + + // MARK: Settings Menu + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + viewModel.playbackSpeed = speed + } label: { + if speed == viewModel.playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -10) + } + } + + Spacer() + + // MARK: Bottom Bar + HStack { + + HStack(spacing: 20) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: "gobackward.10") + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: "goforward.10") + } + } + .font(.system(size: 24, weight: .semibold, design: .default)) + .padding(.trailing, 20) + + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + + Slider(value: $viewModel.sliderPercentage) { editing in + viewModel.sliderIsScrubbing = editing + } + .foregroundColor(.purple) + .tint(.purple) + +// ValueSlider(value: $viewModel.sliderPercentage) +// .valueSliderStyle( +// HorizontalValueSliderStyle(thumb: Circle().foregroundColor(.purple), +// thumbSize: CGSize(width: 32, height: 32), +// thumbInteractiveSize: CGSize(width: 50, height: 50), +// options: [.interactiveTrack]) +// ) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + } + .frame(height: 50) + } + .padding(.top) + .padding(.horizontal) + .ignoresSafeArea(edges: .top) + .tint(Color.white) + .foregroundColor(Color.white) + } + + var body: some View { + mainBody + .background { + Color(uiColor: .black.withAlphaComponent(0.001)) + .ignoresSafeArea() + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } +} + +struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray + .ignoresSafeArea() + + VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 123 * 10_000_000), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + streamURL: URL(string: "www.apple.com")!, + hlsURL: URL(string: "www.apple.com")!, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.5, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1)) + } + .previewInterfaceOrientation(.landscapeLeft) + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift new file mode 100644 index 00000000..cacb1a3e --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -0,0 +1,258 @@ +// +// VLCPlayerOverlayView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/24/21. +// + +import Combine +import MobileVLCKit +import SwiftUI +import JellyfinAPI + + + +struct VLCPlayerOverlayView: View { + + @ObservedObject var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play") + .font(.system(size: 56)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 56)) + default: + ProgressView() + } + } + + @ViewBuilder + private var mainBody: some View { + VStack { + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + + // MARK: Top Bar + HStack(alignment: .top) { + + VStack(alignment: .leading) { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } + } + + Spacer() + + HStack(spacing: 20) { + + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.captionsEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + + // MARK: Settings Menu + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + Button { + print("third pressed") + } label: { + Text("TODO") + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -10) + } + } + + Spacer() + + // MARK: Center Buttons + HStack(spacing: 80) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: "gobackward.10") + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: "goforward.10") + } + } + .font(.system(size: 48)) + + Spacer() + + // MARK: Bottom Bar + HStack { + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + + Slider(value: $viewModel.sliderPercentage) { editing in + viewModel.sliderIsScrubbing = editing + } + .foregroundColor(.purple) + .tint(.purple) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + } + .frame(height: 50) + } + .padding(.top) + .ignoresSafeArea(edges: .vertical) + .tint(Color.white) + .foregroundColor(Color.white) + } + + var body: some View { + mainBody + .background { + Color(uiColor: .black.withAlphaComponent(0.2)) + .ignoresSafeArea() + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } +} + +struct VLCPlayerOverlayView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray + .ignoresSafeArea() + + VLCPlayerOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + streamURL: URL(string: "www.apple.com")!, + hlsURL: URL(string: "www.apple.com")!, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.0, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1)) + } + .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/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift new file mode 100644 index 00000000..399a937f --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -0,0 +1,445 @@ +// +// PlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import AVKit +import AVFoundation +import Combine +import JellyfinAPI +import MediaPlayer +import MobileVLCKit +import SwiftUI +import UIKit + +class VLCPlayerViewController: UIViewController { + + // MARK: variables + + private let viewModel: VideoPlayerViewModel + private var vlcMediaPlayer = VLCMediaPlayer() + private var lastPlayerTicks: Int64 + private var cancellables = Set() + private var overlayDismissTimer: Timer? + + private var currentPlayerTicks: Int64 { + return Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + return overlayHostingController.view.alpha > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var tapGestureView = makeTapGestureView() + private lazy var overlayHostingController = makeOverlayHostingController() + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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() { + 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([ + tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + 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 + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + +// AppUtility.lockOrientation(.all, andRotateTo: .landscapeLeft) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + +// AppUtility.lockOrientation(.all) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + 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) + + viewModel.$screenFilled.sink { shouldFill in + self.changeFill(to: shouldFill) + }.store(in: &cancellables) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if !sliderIsScrubbing { + 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) { + if shouldFill { + // TODO: May not be possible with current VLCKit + +// let drawableView = vlcMediaPlayer.drawable as! UIView +// let drawableViewSize = drawableView.frame.size +// let mediaSize = vlcMediaPlayer.videoSize + + // Largest size from mediaSize is how it is currently filled + // in the drawable view, find scaleFactor by filling entire + // drawableView + + vlcMediaPlayer.scaleFactor = 1.5 + } else { + vlcMediaPlayer.scaleFactor = 0 + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + restartOverlayDismissTimer() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + 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)) + rightSwipeGesture.direction = .right + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + + return view + } + + @objc private func didTap() { + self.didGenerallyTap() + } + + @objc private func didRightSwipe() { + self.didSelectForward() + } + + @objc private func didLeftSwipe() { + self.didSelectBackward() + } + + private func makeOverlayHostingController() -> UIHostingController { + let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) + return UIHostingController(rootView: overlayView) + } +} + +// MARK: setupMediaPlayer +extension VLCPlayerViewController { + + func setupMediaPlayer() { + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + + let media = VLCMedia(url: viewModel.streamURL) + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") + + vlcMediaPlayer.media = media + } + + func startPlayback() { + vlcMediaPlayer.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + + // 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: Show/Hide Overlay +extension VLCPlayerViewController { + + private func showOverlay() { + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + self.overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + self.overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } +} + +// MARK: OverlayTimer +extension VLCPlayerViewController { + + private func restartOverlayDismissTimer(interval: Double = 2) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) + } + + @objc private func dismissTimerFired() { + print("Dismiss timer fired") + self.hideOverlay() + } + + private func stopOverlayDismissTimer() { + self.overlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate +extension VLCPlayerViewController: VLCMediaPlayerDelegate { + + func mediaPlayerStateChanged(_ aNotification: Notification!) { + + self.viewModel.playerState = vlcMediaPlayer.state + + print("Player state changed: \(viewModel.playerState.rawValue)") + } + + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + guard !viewModel.sliderIsScrubbing else { + lastPlayerTicks = currentPlayerTicks + return + } + + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + + viewModel.playerState = VLCMediaPlayerState.playing + } + + lastPlayerTicks = currentPlayerTicks + +// if CACurrentMediaTime() - lastProgressReportTime > 5 { +// mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack +// sendProgressReport(eventName: "timeupdate") +// lastProgressReportTime = CACurrentMediaTime() +// } + } +} + +// MARK: PlayerOverlayDelegate +extension VLCPlayerViewController: PlayerOverlayDelegate { + + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + print("New audio index: \(index)") + } + + func didSelectSubtitleStream(index: Int) { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + if index != -1 { + // set in case weren't shown + viewModel.captionsEnabled = true + } + print("New subtitle index: \(index)") + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport(ticks: currentPlayerTicks) + + dismiss(animated: true, completion: nil) + } + + func didSelectGoogleCast() { + print("didSelectCast") + } + + func didSelectAirplay() { + print("didSelectAirplay") + } + + func didSelectCaptions() { + + viewModel.captionsEnabled = !viewModel.captionsEnabled + + if viewModel.captionsEnabled { + vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32 + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } + + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } + + func didSelectBackward() { + vlcMediaPlayer.jumpBackward(10) + + restartOverlayDismissTimer() + } + + func didSelectForward() { + vlcMediaPlayer.jumpForward(10) + + restartOverlayDismissTimer() + } + + func didSelectMain() { + + switch viewModel.playerState { + case .stopped: () + case .opening: () + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .ended: () + case .error: () + case .playing: + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + vlcMediaPlayer.play() + case .esAdded: () + + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + + } + + func didEndScrubbing(position: Double) { + 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))) + } + + print("Scrubbed position: \(position)") + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard deleted file mode 100644 index 08b8107e..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 2053e08d..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,1188 +0,0 @@ -/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Combine -import Defaults -import GoogleCast -import JellyfinAPI -import MediaPlayer -import MobileVLCKit -import Stinsen -import SwiftUI -import SwiftyJSON - -enum PlayerDestination { - case remote - case local -} - -protocol PlayerViewControllerDelegate: AnyObject { - func hideLoadingView(_ viewController: PlayerViewController) - func showLoadingView(_ viewController: PlayerViewController) - func exitPlayer(_ viewController: PlayerViewController) -} - -class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRemoteMediaClientListener { - @RouterObject - var main: MainCoordinator.Router? - - weak var delegate: PlayerViewControllerDelegate? - - var cancellables = Set() - var mediaPlayer = VLCMediaPlayer() - - @IBOutlet weak var upNextView: UIView! - @IBOutlet weak var timeText: UILabel! - @IBOutlet weak var timeLeftText: UILabel! - @IBOutlet weak var videoContentView: UIView! - @IBOutlet weak var videoControlsView: UIView! - @IBOutlet weak var seekSlider: UISlider! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var jumpBackButton: UIButton! - @IBOutlet weak var jumpForwardButton: UIButton! - @IBOutlet weak var playerSettingsButton: UIButton! - @IBOutlet weak var castButton: UIButton! - - var shouldShowLoadingScreen: Bool = false - var ssTargetValueOffset: Int = 0 - var ssStartValue: Int = 0 - var optionsVC: VideoPlayerSettingsView? - var castDeviceVC: VideoPlayerCastDeviceSelectorView? - - var paused: Bool = true - var lastTime: Float = 0.0 - var startTime: Int = 0 - var controlsAppearTime: Double = 0 - var isSeeking: Bool = false - - var playerDestination: PlayerDestination = .local - var discoveredCastDevices: [GCKDevice] = [] - var selectedCastDevice: GCKDevice? - var jellyfinCastChannel: GCKGenericChannel? - var remotePositionTicks: Int = 0 - private var castDiscoveryManager: GCKDiscoveryManager { - return GCKCastContext.sharedInstance().discoveryManager - } - - private var castSessionManager: GCKSessionManager { - return GCKCastContext.sharedInstance().sessionManager - } - - var hasSentRemoteSeek: Bool = false - - var selectedPlaybackSpeedIndex: Int = 3 - var selectedAudioTrack: Int32 = -1 - var selectedCaptionTrack: Int32 = -1 - var playSessionId: String = "" - var lastProgressReportTime: Double = 0 - var subtitleTrackArray: [Subtitle] = [] - var audioTrackArray: [AudioTrack] = [] - let playbackSpeeds: [Float] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] - var jumpForwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpForward] - } - - var jumpBackwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpBackward] - } - - var manifest = BaseItemDto() - var playbackItem = PlaybackItem() - var remoteTimeUpdateTimer: Timer? - var upNextViewModel = UpNextViewModel() - var lastOri: UIInterfaceOrientation? - - // MARK: IBActions - - @IBAction func seekSliderStart(_ sender: Any) { - if playerDestination == .local { - sendProgressReport(eventName: "pause") - mediaPlayer.pause() - } else { - isSeeking = true - } - } - - @IBAction func seekSliderValueChanged(_ sender: Any) { - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo - - timeText.text = calculateTimeText(from: secondsScrubbedTo) - timeLeftText.text = calculateTimeText(from: secondsScrubbedRemaining) - } - - private func calculateTimeText(from duration: Double) -> String { - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) - - let timeText: String - - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } - - return timeText - } - - @IBAction func seekSliderEnd(_ sender: Any) { - isSeeking = false - let videoPosition = playerDestination == .local ? Double(mediaPlayer.time.intValue / 1000) : - Double(remotePositionTicks / Int(10_000_000)) - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - // Scrub is value from 0..1 - find position in video and add / or remove. - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let offset = secondsScrubbedTo - videoPosition - - if playerDestination == .local { - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - mediaPlayer.play() - sendProgressReport(eventName: "unpause") - } else { - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(secondsScrubbedTo) - ]) - } - } - - @IBAction func exitButtonPressed(_ sender: Any) { - sendStopReport() - mediaPlayer.stop() - - if castSessionManager.hasConnectedCastSession() { - castSessionManager.endSessionAndStopCasting(true) - } - - delegate?.exitPlayer(self) - } - - @IBAction func controlViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = true - if manifest.type == "Episode" { - smallNextUpView() - } - } - } - - @IBAction func contentViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = false - controlsAppearTime = CACurrentMediaTime() - } - } - - @IBAction func jumpBackTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpBackward(jumpBackwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) - Int(jumpBackwardLength.rawValue)]) - } - } - } - - @IBAction func jumpForwardTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpForward(jumpForwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) + Int(jumpForwardLength.rawValue)]) - } - } - } - - @IBOutlet weak var mainActionButton: UIButton! - @IBAction func mainActionButtonPressed(_ sender: Any) { - if paused { - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } else { - sendJellyfinCommand(command: "Unpause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } - } else { - if playerDestination == .local { - mediaPlayer.pause() - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } else { - sendJellyfinCommand(command: "Pause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } - } - } - - @IBAction func settingsButtonTapped(_ sender: UIButton) { - optionsVC = VideoPlayerSettingsView() - optionsVC?.playerDelegate = self - - optionsVC?.modalPresentationStyle = .popover - optionsVC?.popoverPresentationController?.sourceView = playerSettingsButton - - // Present the view controller (in a popover). - present(optionsVC!, animated: true) { - print("popover visible, pause playback") - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } - - // MARK: Cast methods - - @IBAction func castButtonPressed(_ sender: Any) { - if selectedCastDevice == nil { - LogManager.shared.log.debug("Presenting Cast modal") - castDeviceVC = VideoPlayerCastDeviceSelectorView() - castDeviceVC?.delegate = self - - castDeviceVC?.modalPresentationStyle = .popover - castDeviceVC?.popoverPresentationController?.sourceView = castButton - - // Present the view controller (in a popover). - present(castDeviceVC!, animated: true) { - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } else { - LogManager.shared.log.info("Stopping casting session: button was pressed.") - castSessionManager.endSessionAndStopCasting(true) - selectedCastDevice = nil - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - playerDestination = .local - } - } - - func castPopoverDismissed() { - LogManager.shared.log.debug("Cast modal dismissed") - castDeviceVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - } - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - - func castDeviceChanged() { - LogManager.shared.log.debug("Cast device changed") - if selectedCastDevice != nil { - LogManager.shared.log.debug("New device: \(selectedCastDevice?.friendlyName ?? "UNKNOWN")") - playerDestination = .remote - castSessionManager.add(self) - castSessionManager.startSession(with: selectedCastDevice!) - } - } - - // MARK: Cast End - - func settingsPopoverDismissed() { - optionsVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - } - - func setupNowPlayingCC() { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.pauseCommand.isEnabled = true - commandCenter.seekForwardCommand.isEnabled = true - commandCenter.seekBackwardCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.isEnabled = true - - // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.pause() - self.sendProgressReport(eventName: "pause") - } else { - self.sendJellyfinCommand(command: "Pause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - return .success - } - - // Add handler for Play command - commandCenter.playCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.play() - self.sendProgressReport(eventName: "unpause") - } else { - self.sendJellyfinCommand(command: "Unpause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - return .success - } - - // Add handler for FF command - commandCenter.seekForwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpForward(30) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) + 30]) - } - return .success - } - - // Add handler for RW command - commandCenter.seekBackwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpBackward(15) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) - 15]) - } - return .success - } - - // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self] (remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else { return .commandFailed } - - if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { - let targetSeconds = event.positionTime - - let videoPosition = Double(self.mediaPlayer.time.intValue) - let offset = targetSeconds - videoPosition - - if self.playerDestination == .local { - if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset) / 1000) - } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset)) / 1000) - } - self.sendProgressReport(eventName: "unpause") - } else {} - - return .success - } else { - return .commandFailed - } - } - - var nowPlayingInfo = [String: Any]() - - var runTicks = 0 - var playbackTicks = 0 - - if let ticks = manifest.runTimeTicks { - runTicks = Int(ticks / 10_000_000) - } - - if let ticks = manifest.userData?.playbackPositionTicks { - playbackTicks = Int(ticks / 10_000_000) - } - - nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks - - if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) { - if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - artworkImage - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - if manifest.type == "Movie" { - titleLabel.text = manifest.name ?? "" - } else { - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupNextUpView() - upNextViewModel.delegate = self - } - - DispatchQueue.main.async { - self.lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? nil - AppDelegate.orientationLock = .landscape - - if self.lastOri != nil { - if !self.lastOri!.isLandscape { - UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), - name: UIDevice.orientationDidChangeNotification, object: nil) - } - - @objc func didChangedOrientation() { - lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation - } - - func mediaHasStartedPlaying() { - castButton.isHidden = true - let discoveryCriteria = GCKDiscoveryCriteria(applicationID: "F007D354") - let gckCastOptions = GCKCastOptions(discoveryCriteria: discoveryCriteria) - GCKCastContext.setSharedInstanceWith(gckCastOptions) - castDiscoveryManager.passiveScan = true - castDiscoveryManager.add(self) - castDiscoveryManager.startDiscovery() - } - - func didUpdateDeviceList() { - let totalDevices = castDiscoveryManager.deviceCount - discoveredCastDevices = [] - if totalDevices > 0 { - for i in 0 ... totalDevices - 1 { - let device = castDiscoveryManager.device(at: i) - discoveredCastDevices.append(device) - } - } - - if !discoveredCastDevices.isEmpty { - castButton.isHidden = false - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - } else { - castButton.isHidden = true - castButton.isEnabled = false - castButton.setImage(nil, for: .normal) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - tabBarController?.tabBar.isHidden = false - navigationController?.isNavigationBarHidden = false - overrideUserInterfaceStyle = .unspecified - DispatchQueue.main.async { - if self.lastOri != nil { - AppDelegate.orientationLock = .all - UIDevice.current.setValue(self.lastOri!.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - // MARK: viewDidAppear - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - overrideUserInterfaceStyle = .dark - tabBarController?.tabBar.isHidden = true - navigationController?.isNavigationBarHidden = true - - mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - // mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate") - - mediaPlayer.delegate = self - mediaPlayer.drawable = videoContentView - - setupMediaPlayer() - setupJumpLengthButtons() - } - - func setupMediaPlayer() { - // Fetch max bitrate from UserDefaults depending on current connection mode - let maxBitrate = Defaults[.inNetworkBandwidth] - print(maxBitrate) - // Build a device profile - let builder = DeviceProfileBuilder() - builder.setMaxBitrate(bitrate: maxBitrate) - let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, - autoOpenLiveStream: true) - - DispatchQueue.global(qos: .userInitiated).async { [self] in - delegate?.showLoadingView(self) - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, - playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case let .failure(error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - self.delegate?.exitPlayer(self) - SessionManager.main.logout() - case .error: - self.delegate?.exitPlayer(self) - } - } - } - }, receiveValue: { [self] response in - dump(response) - playSessionId = response.playSessionId ?? "" - let mediaSource = response.mediaSources!.first.self! - if mediaSource.transcodingUrl != nil { - // Item is being transcoded by request of server - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(mediaSource.transcodingUrl!)") - let item = PlaybackItem() - item.videoType = .transcode - item.videoUrl = streamURL! - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl ?? "")")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - } else { - // TODO: todo - // Item will be directly played by the client. - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! -// URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag ?? "")")! - - let item = PlaybackItem() - item.videoUrl = streamURL - item.videoType = .directPlay - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec!, - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - - // self.setupNowPlayingCC() - } - - startLocalPlaybackEngine(true) - }) - .store(in: &cancellables) - } - } - - private func setupJumpLengthButtons() { - let buttonFont = UIFont.systemFont(ofSize: 35, weight: .regular) - jumpForwardButton.setImage(jumpForwardLength.generateForwardImage(with: buttonFont), for: .normal) - jumpBackButton.setImage(jumpBackwardLength.generateBackwardImage(with: buttonFont), for: .normal) - } - - func setupTracksForPreferredDefaults() { - subtitleTrackArray.forEach { subtitle in - if Defaults[.isAutoSelectSubtitles] { - if Defaults[.autoSelectSubtitlesLangCode] == "Auto", - subtitle.languageCode.contains(Locale.current.languageCode ?? "") { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } else if subtitle.languageCode.contains(Defaults[.autoSelectSubtitlesLangCode]) { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } - } - } - - audioTrackArray.forEach { audio in - if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) { - selectedAudioTrack = audio.id - mediaPlayer.currentAudioTrackIndex = audio.id - } - } - } - - func startLocalPlaybackEngine(_ fetchCaptions: Bool) { - mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) - mediaPlayer.play() - sendPlayReport() - - // 1 second = 10,000,000 ticks - var startTicks: Int64 = 0 - if remotePositionTicks == 0 { - startTicks = manifest.userData?.playbackPositionTicks ?? 0 - } else { - startTicks = Int64(remotePositionTicks) - } - - if startTicks != 0 { - let videoPosition = Double(mediaPlayer.time.intValue / 1000) - let secondsScrubbedTo = startTicks / 10_000_000 - let offset = secondsScrubbedTo - Int64(videoPosition) - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - } - - if fetchCaptions { - mediaPlayer.pause() - subtitleTrackArray.forEach { sub in - // stupid fxcking jeff decides to re-encode these when added. - // only add playback streams when codec not supported by VLC. - if sub.id != -1, sub.delivery == .external, sub.codec != "subrip" { - mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) - } - } - } - - mediaHasStartedPlaying() - delegate?.hideLoadingView(self) - - videoContentView.setNeedsLayout() - videoContentView.setNeedsDisplay() - view.setNeedsLayout() - view.setNeedsDisplay() - videoControlsView.setNeedsLayout() - videoControlsView.setNeedsDisplay() - - mediaPlayer.pause() - mediaPlayer.play() - setupTracksForPreferredDefaults() - } - - // MARK: VideoPlayerSettings Delegate - - func subtitleTrackChanged(newTrackID: Int32) { - selectedCaptionTrack = newTrackID - mediaPlayer.currentVideoSubTitleIndex = newTrackID - } - - func audioTrackChanged(newTrackID: Int32) { - selectedAudioTrack = newTrackID - mediaPlayer.currentAudioTrackIndex = newTrackID - } - - func playbackSpeedChanged(index: Int) { - selectedPlaybackSpeedIndex = index - mediaPlayer.rate = playbackSpeeds[index] - } - - func smallNextUpView() { - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { [self] in - upNextViewModel.largeView = false - } - } - - func setupNextUpView() { - getNextEpisode() - - // Create the swiftUI view - let contentView = UIHostingController(rootView: VideoUpNextView(viewModel: upNextViewModel)) - upNextView.addSubview(contentView.view) - contentView.view.backgroundColor = .clear - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: upNextView.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: upNextView.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: upNextView.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: upNextView.rightAnchor).isActive = true - } - - func getNextEpisode() { - TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.main.currentLogin.user.id, startItemId: manifest.id, - limit: 2) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { [self] response in - // Returns 2 items, the first is the current episode - // The second is the next episode - if let item = response.items?.last { - self.upNextViewModel.item = item - } - }) - .store(in: &cancellables) - } - - func setPlayerToNextUp() { - mediaPlayer.stop() - - ssTargetValueOffset = 0 - ssStartValue = 0 - - paused = true - lastTime = 0.0 - startTime = 0 - controlsAppearTime = 0 - isSeeking = false - - remotePositionTicks = 0 - - selectedPlaybackSpeedIndex = 3 - selectedAudioTrack = -1 - selectedCaptionTrack = -1 - playSessionId = "" - lastProgressReportTime = 0 - subtitleTrackArray = [] - audioTrackArray = [] - - manifest = upNextViewModel.item! - playbackItem = PlaybackItem() - - upNextViewModel.item = nil - - upNextView.isHidden = true - shouldShowLoadingScreen = true - videoControlsView.isHidden = true - - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupMediaPlayer() - getNextEpisode() - } -} - -// MARK: - GCKGenericChannelDelegate - -extension PlayerViewController: GCKGenericChannelDelegate { - @objc func updateRemoteTime() { - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - if !paused { - remotePositionTicks = remotePositionTicks + 2_000_000 // add 0.2 secs every timer evt. - } - - if isSeeking == false { - let positiveSeconds = Double(remotePositionTicks / 10_000_000) - let remainingSeconds = Double((manifest.runTimeTicks! - Int64(remotePositionTicks)) / 10_000_000) - - timeText.text = calculateTimeText(from: positiveSeconds) - timeLeftText.text = calculateTimeText(from: remainingSeconds) - - let playbackProgress = Float(remotePositionTicks) / Float(manifest.runTimeTicks!) - seekSlider.setValue(playbackProgress, animated: true) - } - } - - func cast(_ channel: GCKGenericChannel, didReceiveTextMessage message: String, withNamespace protocolNamespace: String) { - if let data = message.data(using: .utf8) { - if let json = try? JSON(data: data) { - let messageType = json["type"].string ?? "" - if messageType == "playbackprogress" { - dump(json) - if remotePositionTicks > 100 { - if hasSentRemoteSeek == false { - hasSentRemoteSeek = true - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position) - ]) - } - } - paused = json["data"]["PlayState"]["IsPaused"].boolValue - remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0 - if remoteTimeUpdateTimer == nil { - remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), - userInfo: nil, repeats: true) - } - } - } - } - } - - func sendJellyfinCommand(command: String, options: [String: Any]) { - let payload: [String: Any] = [ - "options": options, - "command": command, - "userId": SessionManager.main.currentLogin.user.id, -// "deviceId": SessionManager.main.currentLogin.de.deviceID, - "accessToken": SessionManager.main.currentLogin.user.accessToken, - "serverAddress": SessionManager.main.currentLogin.server.currentURI, - "serverId": SessionManager.main.currentLogin.server.id, - "serverVersion": "10.8.0", - "receiverName": castSessionManager.currentCastSession!.device.friendlyName!, - "subtitleBurnIn": false - ] - let jsonData = JSON(payload) - - jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil) - - if command == "Seek" { - remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000) - // Send playback report as Jellyfin Chromecast isn't smarter than a rock. - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, - positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } -} - -// MARK: - GCKSessionManagerListener - -extension PlayerViewController: GCKSessionManagerListener { - func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) { - sendStopReport() - mediaPlayer.stop() - - playerDestination = .remote - videoContentView.isHidden = true - videoControlsView.isHidden = false - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - manager.currentCastSession?.start() - - jellyfinCastChannel!.delegate = self - session.add(jellyfinCastChannel!) - - if let client = session.remoteMediaClient { - client.add(self) - } - - let playNowOptions: [String: Any] = [ - "items": [[ - "Id": manifest.id!, - "ServerId": SessionManager.main.currentLogin.server.id, - "Name": manifest.name!, - "Type": manifest.type!, - "MediaType": manifest.mediaType!, - "IsFolder": manifest.isFolder! - ]] - ] - sendJellyfinCommand(command: "PlayNow", options: playNowOptions) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) { - LogManager.shared.log.error((error as NSError).debugDescription) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) { - if error != nil { - LogManager.shared.log.error((error! as NSError).debugDescription) - } - - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) { - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } -} - -// MARK: - VLCMediaPlayer Delegates - -extension PlayerViewController: VLCMediaPlayerDelegate { - func mediaPlayerStateChanged(_ aNotification: Notification!) { - let currentState: VLCMediaPlayerState = mediaPlayer.state - switch currentState { - case .stopped: - LogManager.shared.log.debug("Player state changed: STOPPED") - case .ended: - LogManager.shared.log.debug("Player state changed: ENDED") - case .playing: - LogManager.shared.log.debug("Player state changed: PLAYING") - sendProgressReport(eventName: "unpause") - delegate?.hideLoadingView(self) - paused = false - case .paused: - LogManager.shared.log.debug("Player state changed: PAUSED") - paused = true - case .opening: - LogManager.shared.log.debug("Player state changed: OPENING") - case .buffering: - LogManager.shared.log.debug("Player state changed: BUFFERING") - delegate?.showLoadingView(self) - case .error: - LogManager.shared.log.error("Video had error.") - sendStopReport() - case .esAdded: - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - @unknown default: - break - } - } - - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - let time = mediaPlayer.position - if abs(time - lastTime) > 0.00005 { - paused = false - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - seekSlider.setValue(mediaPlayer.position, animated: true) - delegate?.hideLoadingView(self) - - if manifest.type == "Episode", upNextViewModel.item != nil { - if time > 0.96 { - upNextView.isHidden = false - jumpForwardButton.isHidden = true - } else { - upNextView.isHidden = true - jumpForwardButton.isHidden = false - } - } - - timeText.text = mediaPlayer.time.stringValue - timeLeftText.text = String(mediaPlayer.remainingTime.stringValue.dropFirst()) - - if CACurrentMediaTime() - controlsAppearTime > 5 { - smallNextUpView() - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { - self.videoControlsView.alpha = 0.0 - }, completion: { (_: Bool) in - self.videoControlsView.isHidden = true - self.videoControlsView.alpha = 1 - }) - controlsAppearTime = 999_999_999_999_999 - } - lastTime = time - } - - if CACurrentMediaTime() - lastProgressReportTime > 5 { - mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack - sendProgressReport(eventName: "timeupdate") - lastProgressReportTime = CACurrentMediaTime() - } - } -} - -struct VideoPlayerView: View { - var item: BaseItemDto - @State private var isLoading = false - - var body: some View { - // Loading UI needs to be moved into ViewController later - LoadingViewNoBlur(isShowing: $isLoading) { - VLCPlayerWithControls(item: item, loadBinding: $isLoading) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - } - } -} - -// MARK: End VideoPlayerVC - -struct VLCPlayerWithControls: UIViewControllerRepresentable { - var item: BaseItemDto - @RouterObject var playerRouter: VideoPlayerCoordinator.Router? - - let loadBinding: Binding - - class Coordinator: NSObject, PlayerViewControllerDelegate { - var parent: VLCPlayerWithControls - let loadBinding: Binding - - init(parent: VLCPlayerWithControls, loadBinding: Binding) { - self.parent = parent - self.loadBinding = loadBinding - } - - func hideLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = false - } - - func showLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = true - } - - func exitPlayer(_ viewController: PlayerViewController) { - parent.playerRouter?.dismissCoordinator() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self, loadBinding: loadBinding) - } - - typealias UIViewControllerType = PlayerViewController - func makeUIViewController(context: UIViewControllerRepresentableContext) -> VLCPlayerWithControls - .UIViewControllerType { - let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) - let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController - customViewController.manifest = item - customViewController.delegate = context.coordinator - return customViewController - } - - func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, - context: UIViewControllerRepresentableContext) {} -} - -// MARK: - Play State Update Methods - -extension PlayerViewController { - func sendProgressReport(eventName: String) { - if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) - if ticks == 0 { - ticks = manifest.userData?.playbackPositionTicks ?? 0 - } - - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: mediaPlayer.state == .paused, - isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } - - func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, - positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, - playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", - nowPlayingQueue: []) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback stop report sent!") - }) - .store(in: &cancellables) - } - - func sendPlayReport() { - startTime = Int(Date().timeIntervalSince1970) * 10_000_000 - - print("sending play report!") - - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, - positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], - playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback start report sent!") - }) - .store(in: &cancellables) - } -} - -extension UINavigationController { - override open var childForHomeIndicatorAutoHidden: UIViewController? { - return nil - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift deleted file mode 100644 index 2f018c88..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift +++ /dev/null @@ -1,93 +0,0 @@ -/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import SwiftUI - -class VideoPlayerCastDeviceSelectorView: UIViewController { - private var contentView: UIHostingController! - weak var delegate: PlayerViewController? - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - .landscape - } - - override func viewDidLoad() { - super.viewDidLoad() - contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController())) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - self.delegate?.castPopoverDismissed() - } -} - -struct VideoPlayerCastDeviceSelector: View { - weak var delegate: PlayerViewController! - - init(delegate: PlayerViewController) { - self.delegate = delegate - } - - var body: some View { - NavigationView { - Group { - if !delegate.discoveredCastDevices.isEmpty { - List(delegate.discoveredCastDevices, id: \.deviceID) { device in - HStack { - Text(device.friendlyName!) - .font(.subheadline) - .fontWeight(.medium) - Spacer() - Button { - delegate.selectedCastDevice = device - delegate?.castDeviceChanged() - delegate?.castPopoverDismissed() - } label: { - HStack { - L10n.connect.text - .font(.caption) - .fontWeight(.medium) - Image(systemName: "bonjour") - .font(.caption) - } - } - } - } - } else { - L10n.noCastdevicesfound.text - .foregroundColor(.secondary) - .font(.subheadline) - .fontWeight(.medium) - } - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(L10n.selectCastDestination) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - if UIDevice.current.userInterfaceIdiom == .phone { - Button { - delegate?.castPopoverDismissed() - } label: { - HStack { - Image(systemName: "chevron.left") - L10n.back.text.font(.callout) - } - } - } - } - } - }.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0) - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift deleted file mode 100644 index fb73836a..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift +++ /dev/null @@ -1,89 +0,0 @@ -/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import SwiftUI - -class VideoPlayerSettingsView: UINavigationController { - private var contentView: UIHostingController! - weak var playerDelegate: PlayerViewController? - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - .landscape - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.viewControllers = [UIHostingController(rootView: VideoPlayerSettings(delegate: self.playerDelegate ?? PlayerViewController()))] - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - self.playerDelegate?.settingsPopoverDismissed() - } -} - -struct VideoPlayerSettings: View { - weak var delegate: PlayerViewController! - @State var captionTrack: Int32 = -99 - @State var audioTrack: Int32 = -99 - @State var playbackSpeedSelection: Int = 3 - - init(delegate: PlayerViewController) { - self.delegate = delegate - } - - var body: some View { - Form { - Picker(L10n.closedCaptions, selection: $captionTrack) { - ForEach(delegate.subtitleTrackArray, id: \.id) { caption in - Text(caption.name).tag(caption.id) - } - } - .onChange(of: captionTrack) { track in - self.delegate.subtitleTrackChanged(newTrackID: track) - } - Picker(L10n.audioTrack, selection: $audioTrack) { - ForEach(delegate.audioTrackArray, id: \.id) { caption in - Text(caption.name).tag(caption.id).lineLimit(1) - } - }.onChange(of: audioTrack) { track in - self.delegate.audioTrackChanged(newTrackID: track) - } - Picker(L10n.playbackSpeed, selection: $playbackSpeedSelection) { - ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in - let speed = delegate.playbackSpeeds[speedIndex] - Text("\(String(speed))x").tag(speedIndex) - } - } - .onChange(of: playbackSpeedSelection, perform: { index in - self.delegate.playbackSpeedChanged(index: index) - }) - }.navigationBarTitleDisplayMode(.inline) - .navigationTitle(L10n.audioAndCaptions) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - if UIDevice.current.userInterfaceIdiom == .phone { - Button { - self.delegate.settingsPopoverDismissed() - } label: { - HStack { - Image(systemName: "chevron.left") - L10n.back.text.font(.callout) - } - } - } - } - }.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0) - .onAppear(perform: { - captionTrack = self.delegate.selectedCaptionTrack - audioTrack = self.delegate.selectedAudioTrack - playbackSpeedSelection = self.delegate.selectedPlaybackSpeedIndex - }) - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift new file mode 100644 index 00000000..8f9bf3e9 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift @@ -0,0 +1,41 @@ +// +// VideoPlayerView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import UIKit +import SwiftUI + +struct NativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = NativePlayerViewController + + func makeUIViewController(context: Context) -> NativePlayerViewController { + + return NativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) { + + } +} + +struct VLCPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + return VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift deleted file mode 100644 index 0f886d2c..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI -import JellyfinAPI - -class UpNextViewModel: ObservableObject { - @Published var largeView: Bool = false - @Published var item: BaseItemDto? - weak var delegate: PlayerViewController? - - func nextUp() { - if delegate != nil { - delegate?.setPlayerToNextUp() - } - } -} - -struct VideoUpNextView: View { - - @ObservedObject var viewModel: UpNextViewModel - - var body: some View { - Button { - viewModel.nextUp() - } label: { - HStack { - VStack { - L10n.playNext.text - .foregroundColor(.white) - .font(.subheadline) - .fontWeight(.semibold) - Text(viewModel.item?.getEpisodeLocator() ?? "") - .foregroundColor(.secondary) - .font(.caption) - } - Image(systemName: "play.fill") - .foregroundColor(.white) - .font(.subheadline) - } - .frame(width: 120, height: 35) - .background(Color.jellyfinPurple) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - .shadow(color: .black, radius: 20) - } -} diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index 286c2bd2..6c9e6c44 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -35,8 +35,8 @@ final class ItemCoordinator: NavigationCoordinatable { ItemCoordinator(item: item) } - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) } @ViewBuilder func makeStart() -> some View { diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift deleted file mode 100644 index b4feb6fd..00000000 --- a/Shared/Coordinators/VideoPlayerCoordinator.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -/* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -final class VideoPlayerCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) - - @Root var start = makeStart - - let item: BaseItemDto - - init(item: BaseItemDto) { - self.item = item - } - - @ViewBuilder func makeStart() -> some View { - VideoPlayerView(item: item) - } -} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift new file mode 100644 index 00000000..70c35dfa --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -0,0 +1,42 @@ +// +/* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Defaults +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class VideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + + @Root var start = makeStart + + @Default(.nativeVideoPlayer) var nativeVideoPlayer + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder func makeStart() -> some View { + if nativeVideoPlayer { + NativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + } else { + VLCPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + } + } +} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift new file mode 100644 index 00000000..a8d70173 --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -0,0 +1,35 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Defaults +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class VideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + + @Root var start = makeStart + + @Default(.nativeVideoPlayer) var nativeVideoPlayer + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder func makeStart() -> some View { + NativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } +} + diff --git a/Shared/Extensions/URLComponentsExtensions.swift b/Shared/Extensions/URLComponentsExtensions.swift new file mode 100644 index 00000000..17d1235a --- /dev/null +++ b/Shared/Extensions/URLComponentsExtensions.swift @@ -0,0 +1,22 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation + +extension URLComponents { + + mutating func addQueryItem(name: String, value: String?) { + if let _ = self.queryItems { + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } else { + self.queryItems = [] + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 22146519..06f858ad 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -32,4 +32,5 @@ extension Defaults.Keys { 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 nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index df73d4b6..716dafea 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -102,7 +102,7 @@ final class HomeViewModel: ViewModel { .store(in: &cancellables) ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) @@ -122,7 +122,7 @@ final class HomeViewModel: ViewModel { .store(in: &cancellables) TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters]) .trackActivity(loading) .sink(receiveCompletion: { completion in switch completion { diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index adb14d20..27fd6542 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -7,8 +7,10 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Combine import Foundation import JellyfinAPI +import UIKit class ItemViewModel: ViewModel { @@ -17,6 +19,7 @@ class ItemViewModel: ViewModel { @Published var similarItems: [BaseItemDto] = [] @Published var isWatched = false @Published var isFavorited = false + var itemVideoPlayerViewModel: VideoPlayerViewModel? init(item: BaseItemDto) { self.item = item @@ -32,6 +35,14 @@ class ItemViewModel: ViewModel { super.init() getSimilarItems() + + self.createVideoPlayerViewModel(item: item) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.itemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &cancellables) } func playButtonText() -> String { @@ -100,4 +111,96 @@ class ItemViewModel: ViewModel { .store(in: &cancellables) } } + + func createVideoPlayerViewModel(item: BaseItemDto) -> AnyPublisher { + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + builder.setMaxBitrate(bitrate: 60000000) + let profile = builder.buildProfile() + + let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: item.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true) + + return MediaInfoAPI.getPostedPlaybackInfo(itemId: item.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: item.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + playbackInfoDto: playbackInfo) + .map({ response -> VideoPlayerViewModel in + let mediaSource = response.mediaSources!.first! + + let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? [] + let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? [] + + let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) + + let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) + + let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video }) + + let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! }) + + // MARK: basic stream + var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + streamURL.path = "/Videos/\(item.id!)/stream" + + streamURL.addQueryItem(name: "Static", value: "true") + streamURL.addQueryItem(name: "MediaSourceId", value: item.id!) + streamURL.addQueryItem(name: "Tag", value: item.etag) + streamURL.addQueryItem(name: "MinSegments", value: "6") + + // MARK: hls stream + var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + hlsURL.path = "/videos/\(item.id!)/master.m3u8" + + hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString) + hlsURL.addQueryItem(name: "MediaSourceId", value: item.id!) + hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!) + hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ",")) + hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)") + hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)") + hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)") + hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!) + hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6") + hlsURL.addQueryItem(name: "RequireAvc", value: "false") + hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!) + hlsURL.addQueryItem(name: "SegmentContainer", value: "ts") + hlsURL.addQueryItem(name: "MinSegments", value: "2") + hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true") + hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported") + hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + + if defaultSubtitleStream?.index != nil { + hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") + } + +// startURL.queryItems?.append(URLQueryItem(name: "SubtitleCodec", value: "\(defaultSubtitleStream!.codec!)")) + + let videoPlayerViewModel = VideoPlayerViewModel(item: item, + title: item.name!, + subtitle: item.seriesName, + streamURL: streamURL.url!, + hlsURL: hlsURL.url!, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + defaultAudioStreamIndex: defaultAudioStream?.index ?? -1, + defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: defaultAudioStream?.index != nil, + sliderPercentage: (item.userData?.playedPercentage ?? 0) / 100, + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) + + return videoPlayerViewModel + }) + .eraseToAnyPublisher() + } } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index ae134564..4a951a45 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -83,7 +83,7 @@ final class LibraryViewModel: ViewModel { let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != [] ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive, searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], filters: filters.filters, sortBy: sortBy, tags: filters.tags, enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .trackActivity(loading) @@ -115,7 +115,7 @@ final class LibraryViewModel: ViewModel { let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != [] ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive, searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], filters: filters.filters, sortBy: sortBy, tags: filters.tags, enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .sink(receiveCompletion: { [weak self] completion in diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift new file mode 100644 index 00000000..b3632683 --- /dev/null +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -0,0 +1,218 @@ +// +// VideoPlayerViewModel.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import Combine +import Foundation +import JellyfinAPI +#if os(tvOS) +import TVVLCKit +#else +import MobileVLCKit +#endif +import Stinsen +import UIKit + +final class VideoPlayerViewModel: ObservableObject { + + // Manually kept state because VLCKit doesn't properly set "played" + // on the VLCMediaPlayer object + @Published var playerState: VLCMediaPlayerState + @Published var shouldShowGoogleCast: Bool + @Published var shouldShowAirplay: Bool + @Published var captionsEnabled: Bool + @Published var leftLabelText: String = "--:--" + @Published var rightLabelText: String = "--:--" + @Published var screenFilled: Bool = false + @Published var sliderPercentage: Double { + willSet { + sliderScrubbingSubject.send(self) + sliderPercentageChanged(newValue: newValue) + } + } + @Published var sliderIsScrubbing: Bool = false + @Published var selectedAudioStreamIndex: Int + @Published var selectedSubtitleStreamIndex: Int + + let item: BaseItemDto + let title: String + let subtitle: String? + let streamURL: URL + let hlsURL: URL + // Full response kept for convenience + let response: PlaybackInfoResponse + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let defaultAudioStreamIndex: Int + let defaultSubtitleStreamIndex: Int + + var playerOverlayDelegate: PlayerOverlayDelegate? + + // Ticks of the time the media has begun + var startTimeTicks: Int64? + + // Necessary PassthroughSubject to capture manual scrubbing from sliders + let sliderScrubbingSubject = PassthroughSubject() + + private var cancellables = Set() + + init(item: BaseItemDto, + title: String, + subtitle: String?, + streamURL: URL, + hlsURL: URL, + response: PlaybackInfoResponse, + audioStreams: [MediaStream], + subtitleStreams: [MediaStream], + defaultAudioStreamIndex: Int, + defaultSubtitleStreamIndex: Int, + playerState: VLCMediaPlayerState, + shouldShowGoogleCast: Bool, + shouldShowAirplay: Bool, + subtitlesEnabled: Bool, + sliderPercentage: Double, + selectedAudioStreamIndex: Int, + selectedSubtitleStreamIndex: Int) { + self.item = item + self.title = title + self.subtitle = subtitle + self.streamURL = streamURL + self.hlsURL = hlsURL + self.response = response + self.audioStreams = audioStreams + self.subtitleStreams = subtitleStreams + self.defaultAudioStreamIndex = defaultAudioStreamIndex + self.defaultSubtitleStreamIndex = defaultSubtitleStreamIndex + self.playerState = playerState + self.shouldShowGoogleCast = shouldShowGoogleCast + self.shouldShowAirplay = shouldShowAirplay + self.captionsEnabled = subtitlesEnabled + self.sliderPercentage = sliderPercentage + self.selectedAudioStreamIndex = selectedAudioStreamIndex + self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + + self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100) + } + + private func sliderPercentageChanged(newValue: Double) { + let videoDuration = Double(item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(sliderPercentage * videoDuration) + let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo + + leftLabelText = calculateTimeText(from: secondsScrubbedTo) + rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) + } + + private func calculateTimeText(from duration: Double) -> String { + let hours = floor(duration / 3600) + let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) + + let timeText: String + + if hours != 0 { + timeText = + "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } else { + timeText = + "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } + + return timeText + } + + func sendPlayReport(startTimeTicks: Int64) { + + self.startTimeTicks = startTimeTicks + + let startInfo = PlaybackStartInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, + subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + isPaused: false, + isMuted: false, + positionTicks: item.userData?.playbackPositionTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) + + PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback start report sent!") + } + .store(in: &cancellables) + } + + func sendProgressReport(ticks: Int64) { + + print("Progress ticks: \(ticks)") + + let progressInfo = PlaybackProgressInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, + subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + isPaused: false, + isMuted: false, + positionTicks: ticks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: nil, + brightness: nil, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback progress sent!") + } + .store(in: &cancellables) + } + + func sendStopReport(ticks: Int64) { + + let stopInfo = PlaybackStopInfo(item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + positionTicks: ticks, + liveStreamId: nil, + playSessionId: response.playSessionId, + failed: nil, + nextMediaType: nil, + playlistItemId: "playlistItem0", + nowPlayingQueue: nil) + + PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback stop report sent!") + } + .store(in: &cancellables) + } +}