diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 09d8d485..fa89fb29 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 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 */; }; + 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */; }; 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* LibraryListView.swift */; }; 621338932660107500A81A2A /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 621338B32660A07800A81A2A /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; @@ -495,6 +496,7 @@ 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; }; + 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; 6213388F265F83A900A81A2A /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = ""; }; 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; @@ -1062,6 +1064,15 @@ path = Components; sourceTree = ""; }; + 5D64683B277B15E4009E09AE /* PreferenceUIHosting */ = { + isa = PBXGroup; + children = ( + E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */, + 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */, + ); + path = PreferenceUIHosting; + sourceTree = ""; + }; 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( @@ -1203,7 +1214,7 @@ E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, E13DD3BC27163C63009D4DAF /* EmailHelper.swift */, 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, - E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */, + 5D64683B277B15E4009E09AE /* PreferenceUIHosting */, ); path = App; sourceTree = ""; @@ -1370,6 +1381,7 @@ buildPhases = ( 3D0F2756C71CDF6B9EEBD4E0 /* [CP] Check Pods Manifest.lock */, 6286F0A3271C0ABA00C40ED5 /* R.swift */, + D2E6FAE5F7D441C818F95CD6 /* [CP] Prepare Artifacts */, 5358705C2669D21600D05A09 /* Sources */, 5358705D2669D21600D05A09 /* Frameworks */, 5358705E2669D21600D05A09 /* Resources */, @@ -1403,6 +1415,7 @@ buildPhases = ( 1C7487D3432E90546DA855B5 /* [CP] Check Pods Manifest.lock */, 6286F09E271C093000C40ED5 /* R.swift */, + EF9FEFA814318DC80C582AC6 /* [CP] Prepare Artifacts */, 5377CBED263B596A003A4E83 /* Sources */, 5377CBEE263B596A003A4E83 /* Frameworks */, 5377CBEF263B596A003A4E83 /* Resources */, @@ -1725,6 +1738,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + D2E6FAE5F7D441C818F95CD6 /* [CP] Prepare Artifacts */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Prepare Artifacts"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts.sh\"\n"; + showEnvVarsInLog = 0; + }; D4D3981ADF75BCD341D590C0 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1764,6 +1794,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-resources.sh\"\n"; showEnvVarsInLog = 0; }; + EF9FEFA814318DC80C582AC6 /* [CP] Prepare Artifacts */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Prepare Artifacts"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1996,6 +2043,7 @@ C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, + 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, C40CD922271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, diff --git a/JellyfinPlayer/App/PreferenceUIHostingController.swift b/JellyfinPlayer/App/PreferenceUIHosting/PreferenceUIHostingController.swift similarity index 100% rename from JellyfinPlayer/App/PreferenceUIHostingController.swift rename to JellyfinPlayer/App/PreferenceUIHosting/PreferenceUIHostingController.swift diff --git a/JellyfinPlayer/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/JellyfinPlayer/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift new file mode 100644 index 00000000..7bb70a11 --- /dev/null +++ b/JellyfinPlayer/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -0,0 +1,77 @@ +// + /* + * 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 UIKit +import SwiftUI +import SwizzleSwift + +// MARK: - wrapper view + +/// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. +/// Used in combination with PreferenceUIHostingController. +/// +/// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d +struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { + init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { + _ = UIViewController.preferenceSwizzling + self.wrappedView = wrappedView + } + + var wrappedView: () -> Wrapped + + func makeUIViewController(context: Context) -> PreferenceUIHostingController { + PreferenceUIHostingController(wrappedView: wrappedView()) + } + + func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} +} + +// MARK: - swizzling uiviewcontroller extensions + +extension UIViewController { + static var preferenceSwizzling: Void = { + Swizzle(UIViewController.self) { + #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) + #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) + } + }() +} + +extension UIViewController { + @objc func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } + @objc func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } + + private func search() -> PreferenceUIHostingController? { + if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { + return result + } + + for child in children { + if let result = child.search() { + return result + } + } + + return nil + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift new file mode 100644 index 00000000..fb059f00 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,1190 @@ +/* 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) { + PreferenceUIHostingControllerView { + VLCPlayerWithControls(item: item, loadBinding: $isLoading) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }.edgesIgnoringSafeArea(.all) + } + } +} + +// 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/Podfile b/Podfile index 42260767..167b20a5 100644 --- a/Podfile +++ b/Podfile @@ -9,6 +9,7 @@ target 'JellyfinPlayer iOS' do shared_pods pod 'google-cast-sdk' pod 'MobileVLCKit' + pod 'SwizzleSwift' end target 'JellyfinPlayer tvOS' do platform :tvos, '14.0' @@ -17,4 +18,4 @@ target 'JellyfinPlayer tvOS' do end target 'WidgetExtension' do shared_pods -end \ No newline at end of file +end