diff --git a/kplayer.xcodeproj/project.pbxproj b/kplayer.xcodeproj/project.pbxproj index 2db8be6..0196b74 100644 --- a/kplayer.xcodeproj/project.pbxproj +++ b/kplayer.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 1C7361D2B6E0AE689FAAF4F4 /* VideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736C7FFBDAC665AE04CB65 /* VideoController.swift */; }; 1C7361D3BA77C40275F89D4A /* TimelineMeasure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7368C7B946BC9E067D37E7 /* TimelineMeasure.swift */; }; 1C7361F376DA11F17CD3250B /* TrimView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736ABA0E14A51ACAC84AB5 /* TrimView.swift */; }; + 1C7362AF931E0F228E5D2AED /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7360B53C4C1496320953C2 /* VideoPlayerView.swift */; }; 1C73631EACF56BABD3B2BCFB /* LayoutTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736BC4450890C45F8FBC63 /* LayoutTools.swift */; }; 1C73633AAF0D77F8AC3557B9 /* SVideoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7362603E8588B4D1A8C617 /* SVideoModel.swift */; }; 1C73635138BBD2BB480A308F /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C736777456388CA571DA17B /* MediaPlayer.framework */; }; @@ -43,6 +44,7 @@ 1C73693A1334A7792856FC58 /* MasterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C73611D226B48C24DB37535 /* MasterViewController.swift */; }; 1C736953BDBBAFC40884132A /* BrowserController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C73602350ACE2436736F981 /* BrowserController.swift */; }; 1C73696E4C0353053BF98031 /* links.html in Resources */ = {isa = PBXBuildFile; fileRef = 1C73615FFA2AA98BD1C56CD4 /* links.html */; }; + 1C736998044A9A7D89411892 /* AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7360B6D0757D4FB6433E7B /* AsyncImage.swift */; }; 1C7369ABC44CFB530EA71FB6 /* HeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736D9BB5498E7E8F11C754 /* HeaderCell.swift */; }; 1C736A06A2AD75B8C14EEBBE /* HtmlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736DBB6986A8B62963FBB3 /* HtmlParser.swift */; }; 1C736A5FA5BA53B2597F2ED7 /* Kirschkeks-256x256.png in Resources */ = {isa = PBXBuildFile; fileRef = 1C736059262A57AADE6AB761 /* Kirschkeks-256x256.png */; }; @@ -56,6 +58,7 @@ 1C736D24B49451141CD4B64D /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7369F53095B7A4D65679C2 /* DetailViewController.swift */; }; 1C736D895B75BDCDB35937C1 /* BMTimeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7360AE55EB115762C42EB9 /* BMTimeSlider.swift */; }; 1C736DB41BD06D359E6A0DEE /* BMSubtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7366AAB82A46086690E164 /* BMSubtitles.swift */; }; + 1C736DFD076D9CC30F0B9D58 /* Utility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736677D4EF2437358B2387 /* Utility.swift */; }; 1C736E21B246C0BE7E123FD3 /* MediaModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736B41C6AC33F3FA592C63 /* MediaModel.swift */; }; 1C736EC45EE7DA5F7FCE63DA /* LocalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C73659CC9B523B957E58DC6 /* LocalManager.swift */; }; 1C736ECAE78F5C722423D7ED /* TimelineScroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736362946D7A8585B0D875 /* TimelineScroller.swift */; }; @@ -93,6 +96,8 @@ 1C7360744ABACC3557D05760 /* HanekeFetchOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HanekeFetchOperation.swift; sourceTree = ""; }; 1C7360A94DBECA685ED8602F /* ImageLoadOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoadOperation.swift; sourceTree = ""; }; 1C7360AE55EB115762C42EB9 /* BMTimeSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMTimeSlider.swift; sourceTree = ""; }; + 1C7360B53C4C1496320953C2 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VideoPlayerView.swift; path = svideo/VideoPlayerView.swift; sourceTree = ""; }; + 1C7360B6D0757D4FB6433E7B /* AsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncImage.swift; sourceTree = ""; }; 1C7360D6580FB5D09C2BBCCB /* BMPlayerManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMPlayerManager.swift; sourceTree = ""; }; 1C73610B997EBA367C806C1B /* BMPlayerCompositionResourceDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMPlayerCompositionResourceDefinition.swift; sourceTree = ""; }; 1C73611D226B48C24DB37535 /* MasterViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasterViewController.swift; sourceTree = ""; }; @@ -117,6 +122,7 @@ 1C7365B06FA66294E99AC2D3 /* NetworkManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; 1C7365F45D765A218FFC100F /* BMPlayerProtocols.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMPlayerProtocols.swift; sourceTree = ""; }; 1C73661561AD069C92FE3B15 /* TimelineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TimelineView.swift; path = timeline/TimelineView.swift; sourceTree = ""; }; + 1C736677D4EF2437358B2387 /* Utility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utility.swift; sourceTree = ""; }; 1C7366AAB82A46086690E164 /* BMSubtitles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMSubtitles.swift; sourceTree = ""; }; 1C7366C09381DC0052B52B69 /* EditItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditItemView.swift; sourceTree = ""; }; 1C7366D766CDE0C9872E86F5 /* BMPlayerLayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BMPlayerLayerView.swift; sourceTree = ""; }; @@ -239,6 +245,8 @@ 1C73648CEC974A2500172064 /* ViewControllerExtensions.swift */, 1C7367ECBD369A2A0C94C499 /* FileHelper.swift */, 1C7364709899FF62774B0199 /* VideoHelper.swift */, + 1C736677D4EF2437358B2387 /* Utility.swift */, + 1C7360B6D0757D4FB6433E7B /* AsyncImage.swift */, ); path = util; sourceTree = ""; @@ -344,6 +352,7 @@ 1C736ABA0E14A51ACAC84AB5 /* TrimView.swift */, 1C73624617102E0DEB001C25 /* SVideoPlayer.swift */, 1C7362603E8588B4D1A8C617 /* SVideoModel.swift */, + 1C7360B53C4C1496320953C2 /* VideoPlayerView.swift */, ); path = kplayer; sourceTree = ""; @@ -596,6 +605,9 @@ 1C736A78C1F8F41E2AEEF278 /* KVideoPlayer.swift in Sources */, 1C736FF8FF423F01F880F94D /* SVideoPlayer.swift in Sources */, 1C73633AAF0D77F8AC3557B9 /* SVideoModel.swift in Sources */, + 1C7362AF931E0F228E5D2AED /* VideoPlayerView.swift in Sources */, + 1C736DFD076D9CC30F0B9D58 /* Utility.swift in Sources */, + 1C736998044A9A7D89411892 /* AsyncImage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/kplayer/core/KSettings.swift b/kplayer/core/KSettings.swift index 95a1054..55ab21f 100644 --- a/kplayer/core/KSettings.swift +++ b/kplayer/core/KSettings.swift @@ -15,6 +15,9 @@ class KSettings: ObservableObject { @Published var edit = false + @Published + var newPlayer = true + convenience init(model: KSettingsModel) { self.init() scale = model.scale diff --git a/kplayer/core/MediaItem.swift b/kplayer/core/MediaItem.swift index b9fc6cd..70236bb 100644 --- a/kplayer/core/MediaItem.swift +++ b/kplayer/core/MediaItem.swift @@ -37,6 +37,7 @@ class MediaItem: CustomDebugStringConvertible, ObservableObject, Identifiable { var sortName = "" // Nutzinhalt + @Published var image: UIImage? // let didChange = PassthroughSubject() diff --git a/kplayer/detail/DetailViewController.swift b/kplayer/detail/DetailViewController.swift index 9b0408d..d18bb4c 100644 --- a/kplayer/detail/DetailViewController.swift +++ b/kplayer/detail/DetailViewController.swift @@ -31,8 +31,6 @@ class DetailViewController: UIViewController, UICollectionViewDelegateFlowLayout var showFavoritesOnly = false var collectionView: UICollectionView! - var videoplayer = true - var currentItem: MediaItem? var defaultItemSize = CGSize(width: (15 * 16) - 6, height: 15 * 9) @@ -93,9 +91,8 @@ class DetailViewController: UIViewController, UICollectionViewDelegateFlowLayout let settingsButton = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(settings)); let overviewButton = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(overview)); let favButton = UIBarButtonItem(barButtonSystemItem: .bookmarks, target: self, action: #selector(favorites)); - let vidButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(vplayer)); let browserButton = UIBarButtonItem(barButtonSystemItem: .organize, target: self, action: #selector(fileBrowser)); - navigationItem.rightBarButtonItems = [settingsButton, vidButton, favButton, overviewButton,browserButton] + navigationItem.rightBarButtonItems = [settingsButton, favButton, overviewButton,browserButton] if detailItem != nil { print("Details \(detailItem!.children)") } @@ -147,10 +144,6 @@ class DetailViewController: UIViewController, UICollectionViewDelegateFlowLayout present(fileBrowser, animated: true, completion: nil) } - @objc func vplayer() { - videoplayer = !videoplayer - } - @objc func favorites() { showFavoritesOnly = !showFavoritesOnly collectionView.reloadData() @@ -469,8 +462,8 @@ class DetailViewController: UIViewController, UICollectionViewDelegateFlowLayout func showVideo(selectedItem: MediaItem) { var se = selectedItem - var children = detailItem!.children - if videoplayer { + var children = selectedItem.parent!.children + if delegate!.settings().newPlayer { let model = SVideoModel(allItems: children, currentSnapshot: se) let player = SVideoPlayer(completionHandler: { diff --git a/kplayer/master/KSettingsView.swift b/kplayer/master/KSettingsView.swift index 79d4b39..590d32f 100644 --- a/kplayer/master/KSettingsView.swift +++ b/kplayer/master/KSettingsView.swift @@ -24,6 +24,9 @@ struct KSettingsView: View { Toggle(isOn: $kSettings.edit, label: { Text("Edit") }) + Toggle(isOn: $kSettings.newPlayer, label: { + Text("New Player") + }) } } Button(action: { diff --git a/kplayer/svideo/SVideoPlayer.swift b/kplayer/svideo/SVideoPlayer.swift index 0f3a500..cfe8df2 100644 --- a/kplayer/svideo/SVideoPlayer.swift +++ b/kplayer/svideo/SVideoPlayer.swift @@ -7,52 +7,125 @@ import Foundation import SwiftUI import AVKit -struct SVideoPlayer : View { +struct SVideoPlayer: View { // url: URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")! var player = AVQueuePlayer(items: [AVPlayerItem]()) var completionHandler: (() -> Void)? var model: SVideoModel + // The progress through the video, as a percentage (from 0 to 1) + @State private var videoPos: Double = 0 + // The duration of the video in seconds + @State private var videoDuration: Double = 0 + // Whether we're currently interacting with the seek bar or doing a seek + @State private var seeking = false + + @State private var scale: CGFloat = 1.0 + @State private var lastScaleValue: CGFloat = 1.0 + + @State private var lastDragOffset: CGSize = CGSize.zero + @State private var dragOffset: CGSize = CGSize.zero + var body: some View { - HStack { - Button(action: { - completionHandler!() - }, label: { - Text("cancel") - }).buttonStyle(BorderlessButtonStyle()); - - ScrollView (.horizontal, showsIndicators: false) { - HStack { - ForEach(model.allItems) { item in - Button(action: { - - }) { - if item.image != nil { - Image(uiImage: item.image!) - } - else { - Image("Kirschkeks-256x256.png") + VStack { + HStack { + Button(action: { + completionHandler!() + }, label: { + Text("cancel") + }).buttonStyle(BorderlessButtonStyle()); + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(model.allItems) { item in + Button(action: { + gotoSnapshot(item) + }) { + AsyncImage(item: item, placeholder: { Text("Loading ...") }, + image: { Image(uiImage: $0).resizable() }) } } } - } - }.frame(height: 100) + }.frame(height: 50) + + Spacer() + } + + VideoPlayerView(videoPos: $videoPos, + videoDuration: $videoDuration, + seeking: $seeking, + player: player) + .scaleEffect(scale) + .offset(dragOffset) + .gesture( + DragGesture() + .onChanged { gesture in + + let dragged = gesture.translation - Spacer() + if move(dragged) { + // lastDragOffset = gesture.translation + dragOffset = CGSize(width: dragged.width + lastDragOffset.width, height: dragged.height + lastDragOffset.height) + } + } + .onEnded { gesture in + lastDragOffset = dragOffset + } + ) + .gesture(MagnificationGesture().onChanged { val in + let delta = val / self.lastScaleValue + self.lastScaleValue = val + self.scale = self.scale * delta + +//... anything else e.g. clamping the newScale + }.onEnded { val in + // without this the next gesture will be broken + self.lastScaleValue = 1.0 + }).clipped() + + VideoPlayerControlsView(videoPos: $videoPos, + videoDuration: $videoDuration, + seeking: $seeking, + player: player) } - VideoPlayer(player: player) .onAppear() { - player.removeAllItems() - player.insert(model.currentPlayerItem(), after: nil) + let item = model.currentPlayerItem() + self.player.replaceCurrentItem(with: item) + // player.removeAllItems() + // player.insert(model.currentPlayerItem(), after: nil) // Start the player going, otherwise controls don't appear player.play() } - .onDisappear() { - // Stop the player when the view disappears - player.pause() + .onDisappear { + // When this View isn't being shown anymore stop the player + self.player.replaceCurrentItem(with: nil) } } + + private func move(_ dragged: CGSize) -> Bool { + // if player.status == .playing { + return true + //} + } + + func gotoSnapshot(_ currentSnapshot: MediaItem) { + print(currentSnapshot.time) + + player.seek(to: CMTime(seconds: currentSnapshot.time, preferredTimescale: CMTimeScale(10000))) + // player.forceSeekSmoothlyToTime(newChaseTime: currentSnapshot.time) +// loopStart = currentSnapshot.time +// player.loopEnd = loopStart + currentSnapshot.length +// +// if loopMode && currentSnapshot.scale > 0 { +// player.zoom = Float(currentSnapshot.scale) +// player.xpos = currentSnapshot.offset.x +// player.ypos = currentSnapshot.offset.y +// player.transformLayer() +// } + + model.currentSnapshot = currentSnapshot + } } struct SVideoPlayer_Previews: PreviewProvider { diff --git a/kplayer/svideo/VideoPlayerView.swift b/kplayer/svideo/VideoPlayerView.swift new file mode 100644 index 0000000..5f7124f --- /dev/null +++ b/kplayer/svideo/VideoPlayerView.swift @@ -0,0 +1,170 @@ +// +// Created by Marco Schmickler on 28.11.21. +// Copyright (c) 2021 Marco Schmickler. All rights reserved. +// + +import Foundation + +import SwiftUI +import AVFoundation + +// This is the UIView that contains the AVPlayerLayer for rendering the video +class VideoPlayerUIView: UIView { + private let player: AVPlayer + private let playerLayer = AVPlayerLayer() + private let videoPos: Binding + private let videoDuration: Binding + private let seeking: Binding + private var durationObservation: NSKeyValueObservation? + private var timeObservation: Any? + + init(player: AVPlayer, videoPos: Binding, videoDuration: Binding, seeking: Binding) { + self.player = player + self.videoDuration = videoDuration + self.videoPos = videoPos + self.seeking = seeking + + super.init(frame: .zero) + + backgroundColor = .black + playerLayer.player = player + layer.addSublayer(playerLayer) + + // Observe the duration of the player's item so we can display it + // and use it for updating the seek bar's position + durationObservation = player.currentItem?.observe(\.duration, changeHandler: { [weak self] item, change in + guard let self = self else { return } + self.videoDuration.wrappedValue = item.duration.seconds + }) + + // Observe the player's time periodically so we can update the seek bar's + // position as we progress through playback + timeObservation = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { [weak self] time in + guard let self = self else { return } + // If we're not seeking currently (don't want to override the slider + // position if the user is interacting) + guard !self.seeking.wrappedValue else { + return + } + + self.videoDuration.wrappedValue = self.player.currentItem!.duration.seconds + // update videoPos with the new video time (as a percentage) + self.videoPos.wrappedValue = time.seconds / self.videoDuration.wrappedValue + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + playerLayer.frame = bounds + } + + func cleanUp() { + // Remove observers we setup in init + durationObservation?.invalidate() + durationObservation = nil + + if let observation = timeObservation { + player.removeTimeObserver(observation) + timeObservation = nil + } + } + +} + +// This is the SwiftUI view which wraps the UIKit-based PlayerUIView above +struct VideoPlayerView: UIViewRepresentable { + @Binding private(set) var videoPos: Double + @Binding private(set) var videoDuration: Double + @Binding private(set) var seeking: Bool + + let player: AVPlayer + + func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { + // This function gets called if the bindings change, which could be useful if + // you need to respond to external changes, but we don't in this example + } + + func makeUIView(context: UIViewRepresentableContext) -> UIView { + let uiView = VideoPlayerUIView(player: player, + videoPos: $videoPos, + videoDuration: $videoDuration, + seeking: $seeking) + return uiView + } + + static func dismantleUIView(_ uiView: UIView, coordinator: ()) { + guard let playerUIView = uiView as? VideoPlayerUIView else { + return + } + + playerUIView.cleanUp() + } +} + +// This is the SwiftUI view that contains the controls for the player +struct VideoPlayerControlsView : View { + @Binding private(set) var videoPos: Double + @Binding private(set) var videoDuration: Double + @Binding private(set) var seeking: Bool + + let player: AVPlayer + + @State var playerPaused = true + + var body: some View { + HStack { + // Play/pause button + Button(action: togglePlayPause) { + Image(systemName: playerPaused ? "play" : "pause") + .padding(.trailing, 10) + } + // Current video time + Text("\(Utility.formatSecondsToHMS(videoPos * videoDuration))") + // Slider for seeking / showing video progress + Slider(value: $videoPos, in: 0...1, onEditingChanged: sliderEditingChanged) + // Video duration + Text("\(Utility.formatSecondsToHMS(videoDuration))") + } + .padding(.leading, 10) + .padding(.trailing, 10) + } + + private func togglePlayPause() { + pausePlayer(!playerPaused) + } + + private func pausePlayer(_ pause: Bool) { + playerPaused = pause + if playerPaused { + player.pause() + } + else { + player.play() + } + } + + private func sliderEditingChanged(editingStarted: Bool) { + if editingStarted { + // Set a flag stating that we're seeking so the slider doesn't + // get updated by the periodic time observer on the player + seeking = true + pausePlayer(true) + } + + // Do the seek if we're finished + if !editingStarted { + let targetTime = CMTime(seconds: videoPos * videoDuration, + preferredTimescale: 600) + player.seek(to: targetTime) { _ in + // Now the seek is finished, resume normal operation + self.seeking = false + self.pausePlayer(false) + } + } + } +} diff --git a/kplayer/util/AsyncImage.swift b/kplayer/util/AsyncImage.swift new file mode 100644 index 0000000..f7bcce9 --- /dev/null +++ b/kplayer/util/AsyncImage.swift @@ -0,0 +1,51 @@ +import SwiftUI +import Haneke + +struct AsyncImage: View { + @StateObject private var item: MediaItem + private let placeholder: Placeholder + private let image: (UIImage) -> Image + + init( + item: MediaItem, + @ViewBuilder placeholder: () -> Placeholder, + @ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:) + ) { + self.placeholder = placeholder() + self.image = image + _item = StateObject(wrappedValue: item) + + setImage(newItem: item) + } + + var body: some View { + content + // .onAppear(perform: loader.load) + } + + private var content: some View { + Group { + if item.image != nil { + image(item.image!) + } else { + placeholder + } + } + } + + func setImage(newItem: MediaItem) { + if newItem.image != nil { + let icon = newItem.image!.scaleToSize(66.0, height: 44.0) + } else { + if newItem.thumbUrl != nil { + let URL = Foundation.URL(string: newItem.thumbUrlAbsolute)! + + Shared.imageCache.fetch(URL: URL).onSuccess { + i in + newItem.image = i.scaleToSize(66.0, height: 44.0) + + } + } + } + } +} diff --git a/kplayer/util/Utility.swift b/kplayer/util/Utility.swift new file mode 100644 index 0000000..f5b8c37 --- /dev/null +++ b/kplayer/util/Utility.swift @@ -0,0 +1,30 @@ +// +// Utility.swift +// AVPlayer-SwiftUI +// +// Created by Chris Mash on 11/09/2019. +// Copyright © 2019 Chris Mash. All rights reserved. +// + +import Foundation + +class Utility: NSObject { + + private static var timeHMSFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = [.pad] + return formatter + }() + + static func formatSecondsToHMS(_ seconds: Double) -> String { + guard !seconds.isNaN, + let text = timeHMSFormatter.string(from: seconds) else { + return "00:00" + } + + return text + } + +}