// /* * 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.uri)\(transcodiungUrl)")! } // Item will be directly played by the client else { item.videoType = .directPlay // streamURL = 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!)")! streamURL = URL(string: "\(SessionManager.main.currentLogin.server.uri)/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.uri)\(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) } }