/* 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.uri)\(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.uri)\(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.uri)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! // URL(string: "\(SessionManager.main.currentLogin.server.uri)/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.uri)\(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.uri, "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 } }