diff --git a/Example/ContentView.swift b/Example/ContentView.swift index d1f6869..32fe973 100644 --- a/Example/ContentView.swift +++ b/Example/ContentView.swift @@ -12,8 +12,8 @@ import AVKit struct ContentView: View { var body: some View { VStack { - let player = TPAVPlayer(assetID: "peBmzxeQ7Mf", - accessToken: "d7ebb4b2-8dee-4dff-bb00-e833195b0756") + let player = TPAVPlayer(assetID: "8eaHZjXt6km", + accessToken: "16b608ba-9979-45a0-94fb-b27c1a86b3c1") TPStreamPlayerView(player: player) .frame(height: 240) Spacer() diff --git a/Source/Database/Model/OfflineAsset.swift b/Source/Database/Model/OfflineAsset.swift index 241c83f..effefed 100644 --- a/Source/Database/Model/OfflineAsset.swift +++ b/Source/Database/Model/OfflineAsset.swift @@ -25,6 +25,27 @@ public class OfflineAsset: Object { public static var manager = ObjectManager() } +extension OfflineAsset { + static func create( + assetId: String, + srcURL: String, + title: String, + resolution: String, + duration:Double, + bitRate: Double + ) -> OfflineAsset { + let offlineAsset = OfflineAsset() + offlineAsset.assetId = assetId + offlineAsset.srcURL = srcURL + offlineAsset.title = title + offlineAsset.resolution = resolution + offlineAsset.duration = duration + offlineAsset.bitRate = bitRate + offlineAsset.size = (bitRate * duration) + return offlineAsset + } +} + enum Status: String { case notStarted = "notStarted" case inProgress = "inProgress" diff --git a/Source/Database/ObjectManager.swift b/Source/Database/ObjectManager.swift index 3e1ecf1..2ae93c7 100644 --- a/Source/Database/ObjectManager.swift +++ b/Source/Database/ObjectManager.swift @@ -20,7 +20,7 @@ public class ObjectManager { func get(id: Any) -> T? { return realm.object(ofType: T.self, forPrimaryKey: id) } - + func update(object: T, with attributes: [String: Any]) { try! realm.write { for (key, value) in attributes { @@ -28,4 +28,9 @@ public class ObjectManager { } } } + + func exists(id: Any) -> Bool { + let object = realm.object(ofType: T.self, forPrimaryKey: id) + return object != nil + } } diff --git a/Source/Database/TPStreamsDownloadManager.swift b/Source/Database/TPStreamsDownloadManager.swift new file mode 100644 index 0000000..52c6c75 --- /dev/null +++ b/Source/Database/TPStreamsDownloadManager.swift @@ -0,0 +1,100 @@ +// +// TPStreamsDownloadManager.swift +// TPStreamsSDK +// +// Created by Prithuvi on 08/01/24. +// + +import Foundation +import AVFoundation + + +public final class TPStreamsDownloadManager { + + static public let shared = TPStreamsDownloadManager() + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private var assetDownloadDelegate: AssetDownloadDelegate! + + private init() { + let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "com.tpstreams.downloadSession") + assetDownloadDelegate = AssetDownloadDelegate() + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: backgroundConfiguration, + assetDownloadDelegate: assetDownloadDelegate, + delegateQueue: OperationQueue.main + ) + } + + internal func startDownload(asset: Asset, videoQuality: VideoQuality) { + + if OfflineAsset.manager.exists(id: asset.id) { return } + + let avUrlAsset = AVURLAsset(url: URL(string: asset.video.playbackURL)!) + + guard let task = assetDownloadURLSession.aggregateAssetDownloadTask( + with: avUrlAsset, + mediaSelections: [avUrlAsset.preferredMediaSelection], + assetTitle: asset.title, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: videoQuality.bitrate] + ) else { return } + + let offlineAsset = OfflineAsset.create( + assetId: asset.id, + srcURL: asset.video.playbackURL, + title: asset.title, + resolution:videoQuality.resolution, + duration: asset.video.duration, + bitRate: videoQuality.bitrate + ) + OfflineAsset.manager.add(object: offlineAsset) + assetDownloadDelegate.activeDownloadsMap[task] = offlineAsset + task.resume() + } + +} + +internal class AssetDownloadDelegate: NSObject, AVAssetDownloadDelegate { + + var activeDownloadsMap = [AVAggregateAssetDownloadTask: OfflineAsset]() + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let assetDownloadTask = task as? AVAggregateAssetDownloadTask else { return } + guard let offlineAsset = activeDownloadsMap[assetDownloadTask] else { return } + updateDownloadCompleteStatus(error, offlineAsset) + activeDownloadsMap.removeValue(forKey: assetDownloadTask) + } + + func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, willDownloadTo location: URL) { + guard let offlineAsset = activeDownloadsMap[aggregateAssetDownloadTask] else { return } + OfflineAsset.manager.update(object: offlineAsset, with: ["downloadedPath": String(location.relativePath)]) + } + + func urlSession(_ session: URLSession, + aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange, + for mediaSelection: AVMediaSelection + ) { + guard let offlineAsset = activeDownloadsMap[aggregateAssetDownloadTask] else { return } + + let percentageComplete = calculateDownloadPercentage(loadedTimeRanges, timeRangeExpectedToLoad) + OfflineAsset.manager.update(object: offlineAsset, with: ["status": Status.inProgress.rawValue, "percentageCompleted": percentageComplete]) + } + + private func updateDownloadCompleteStatus(_ error: Error?,_ offlineAsset: OfflineAsset) { + let status: Status = (error == nil) ? .finished : .failed + let updateValues: [String: Any] = ["status": status.rawValue, "downloadedAt": Date()] + OfflineAsset.manager.update(object: offlineAsset, with: updateValues) + } + + private func calculateDownloadPercentage(_ loadedTimeRanges: [NSValue], _ timeRangeExpectedToLoad: CMTimeRange) -> Double { + var percentageComplete = 0.0 + for value in loadedTimeRanges { + let loadedTimeRange = value.timeRangeValue + percentageComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds + } + return percentageComplete * 100 + } +} diff --git a/Source/Managers/TPStreamPlayer.swift b/Source/Managers/TPStreamPlayer.swift index 701d528..c3297c2 100644 --- a/Source/Managers/TPStreamPlayer.swift +++ b/Source/Managers/TPStreamPlayer.swift @@ -26,6 +26,9 @@ class TPStreamPlayer: NSObject { var currentVideoQuality: VideoQuality? { return self.player.availableVideoQualities.first( where: {$0.bitrate == self.player.currentItem?.preferredPeakBitRate }) } + var asset: Asset? { + return self.player.asset + } init(player: TPAVPlayer){ self.player = player diff --git a/Source/TPStreamPlayerConfiguration.swift b/Source/TPStreamPlayerConfiguration.swift index 93639d2..99f510b 100644 --- a/Source/TPStreamPlayerConfiguration.swift +++ b/Source/TPStreamPlayerConfiguration.swift @@ -13,6 +13,7 @@ public struct TPStreamPlayerConfiguration { public var preferredRewindDuration: TimeInterval = 10.0 public var watchedProgressTrackColor: UIColor = .red public var progressBarThumbColor: UIColor = .red + public var showDownloadOption: Bool = false } @@ -43,6 +44,11 @@ public class TPStreamPlayerConfigurationBuilder { return self } + public func showDownloadOption() -> Self { + configuration.showDownloadOption = true + return self + } + public func build() -> TPStreamPlayerConfiguration { return configuration } diff --git a/Source/TPStreamsSDK.swift b/Source/TPStreamsSDK.swift index 1fc6263..5d6a502 100644 --- a/Source/TPStreamsSDK.swift +++ b/Source/TPStreamsSDK.swift @@ -52,7 +52,7 @@ public class TPStreamsSDK { options.attachViewHierarchy = true } } - + private static func initializeDatabase() { Realm.Configuration.defaultConfiguration = Realm.Configuration(schemaVersion: 1) } diff --git a/Source/Views/SwiftUI/PlayerSettingsButton.swift b/Source/Views/SwiftUI/PlayerSettingsButton.swift index 3210e53..2d247a3 100644 --- a/Source/Views/SwiftUI/PlayerSettingsButton.swift +++ b/Source/Views/SwiftUI/PlayerSettingsButton.swift @@ -29,7 +29,7 @@ struct PlayerSettingsButton: View { return ActionSheet( title: Text("Settings"), message: nil, - buttons: [playbackSpeedButton(), videoQualityButton(), .cancel()] + buttons: [playbackSpeedButton(), videoQualityButton(), downloadQualityButton(), .cancel()] ) case .playbackSpeed: return ActionSheet( @@ -43,6 +43,12 @@ struct PlayerSettingsButton: View { message: nil, buttons: videoQualityOptions() + [.cancel()] ) + case .downloadQuality: + return ActionSheet( + title: Text("Download Quality"), + message: nil, + buttons: downloadQualityOptions() + [.cancel()] + ) } } @@ -64,6 +70,15 @@ struct PlayerSettingsButton: View { } } + private func downloadQualityButton() -> ActionSheet.Button { + return .default(Text("Download")) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.showOptions = true + self.currentMenu = .downloadQuality + } + } + } + private func playbackSpeedOptions() -> [ActionSheet.Button] { let playbackSpeeds = PlaybackSpeed.allCases return playbackSpeeds.map { speed in @@ -80,6 +95,17 @@ struct PlayerSettingsButton: View { } } } + + private func downloadQualityOptions() -> [ActionSheet.Button] { + var availableVideoQualities = player.availableVideoQualities + // Remove Auto Quality from the Array + availableVideoQualities.remove(at: 0) + return availableVideoQualities.map { downloadQuality in + .default(Text(downloadQuality.resolution)) { + TPStreamsDownloadManager.shared.startDownload(asset: player.asset!, videoQuality: downloadQuality) + } + } + } } -enum SettingsMenu { case main, playbackSpeed, videoQuality } +enum SettingsMenu { case main, playbackSpeed, videoQuality, downloadQuality } diff --git a/Source/Views/UIKit/PlayerControlsUIView.swift b/Source/Views/UIKit/PlayerControlsUIView.swift index 4c932ba..618f6ca 100644 --- a/Source/Views/UIKit/PlayerControlsUIView.swift +++ b/Source/Views/UIKit/PlayerControlsUIView.swift @@ -119,9 +119,12 @@ class PlayerControlsUIView: UIView { optionsMenu.addAction(UIAlertAction(title: "Playback Speed", style: .default) { _ in self.showPlaybackSpeedMenu()}) optionsMenu.addAction(UIAlertAction(title: "Video Quality", style: .default, handler: { action in self.showVideoQualityMenu()})) optionsMenu.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + if playerConfig.showDownloadOption { + optionsMenu.addAction(UIAlertAction(title: "Download", style: .default, handler: { action in self.showDownloadQualityMenu()})) + } parentViewController?.present(optionsMenu, animated: true, completion: nil) } - + func showPlaybackSpeedMenu(){ let playbackSpeedMenu = createPlaybackSpeedMenu() parentViewController?.present(playbackSpeedMenu, animated: true, completion: nil) @@ -132,14 +135,19 @@ class PlayerControlsUIView: UIView { parentViewController?.present(videoQualityMenu, animated: true, completion: nil) } + func showDownloadQualityMenu(){ + let downloadQualityMenu = createDownloadQualityMenu() + parentViewController?.present(downloadQualityMenu, animated: true, completion: nil) + } + func createPlaybackSpeedMenu() -> UIAlertController { let playbackSpeedMenu = UIAlertController(title: "Playback Speed", message: nil, preferredStyle: ACTION_SHEET_PREFERRED_STYLE) - + for playbackSpeed in PlaybackSpeed.allCases { let action = createActionForPlaybackSpeed(playbackSpeed) playbackSpeedMenu.addAction(action) } - + playbackSpeedMenu.addAction(UIAlertAction(title: "Cancel", style: .cancel)) return playbackSpeedMenu } @@ -153,12 +161,25 @@ class PlayerControlsUIView: UIView { qualityMenu.addAction(UIAlertAction(title: "Cancel", style: .cancel)) return qualityMenu } - + + func createDownloadQualityMenu() -> UIAlertController { + let qualityMenu = UIAlertController(title: "Available resolutions", message: nil, preferredStyle: ACTION_SHEET_PREFERRED_STYLE) + var availableVideoQualities = player.availableVideoQualities + // Remove Auto Quality from the Array + availableVideoQualities.remove(at: 0) + for quality in availableVideoQualities { + let action = createActionForDownload(quality) + qualityMenu.addAction(action) + } + qualityMenu.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + return qualityMenu + } + func createActionForPlaybackSpeed(_ playbackSpeed: PlaybackSpeed) -> UIAlertAction { let action = UIAlertAction(title: playbackSpeed.label, style: .default) { [weak self] _ in self?.player.changePlaybackSpeed(playbackSpeed) } - + if playbackSpeed == .normal && self.player.currentPlaybackSpeed.rawValue == 0.0 || (playbackSpeed.rawValue == self.player.currentPlaybackSpeed.rawValue) { action.setValue(UIImage(named: "checkmark", in: bundle, compatibleWith: nil), forKey: "image") } @@ -176,7 +197,15 @@ class PlayerControlsUIView: UIView { return action } - + + func createActionForDownload(_ quality: VideoQuality) -> UIAlertAction { + let action = UIAlertAction(title: quality.resolution, style: .default, handler: { (_) in + TPStreamsDownloadManager.shared.startDownload(asset: self.player.asset!, videoQuality: quality) + }) + + return action + } + @IBAction func toggleFullScreen(_ sender: Any) { if isFullScreen { fullScreenToggleDelegate?.exitFullScreen() diff --git a/StoryboardExample/ViewController.swift b/StoryboardExample/ViewController.swift index 3fc7097..b39c451 100644 --- a/StoryboardExample/ViewController.swift +++ b/StoryboardExample/ViewController.swift @@ -40,6 +40,7 @@ class ViewController: UIViewController { .setPreferredRewindDuration(5) .setprogressBarThumbColor(.systemBlue) .setwatchedProgressTrackColor(.systemBlue) + .showDownloadOption() .build() playerViewController?.config = config diff --git a/Tests/ObjectManagerTest.swift b/Tests/ObjectManagerTest.swift new file mode 100644 index 0000000..10e6bc9 --- /dev/null +++ b/Tests/ObjectManagerTest.swift @@ -0,0 +1,33 @@ +// +// ObjectManagerTest.swift +// iOSPlayerSDKTests +// +// Created by Prithuvi on 10/01/24. +// + +import XCTest + +import XCTest +@testable import TPStreamsSDK +import RealmSwift + +final class ObjectManagerTest: XCTestCase { + + override func setUp() { + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = self.name + } + + func testAddAndGetObject() throws { + let offlineAsset = OfflineAsset() + offlineAsset.assetId = "test" + offlineAsset.srcURL = "https://www.test.com.m3u8" + // Add object + OfflineAsset.manager.add(object: offlineAsset) + // Get object + let retrivedOfflineAsset = OfflineAsset.manager.get(assetId: "test") + + XCTAssert(retrivedOfflineAsset!.assetId == "test") + XCTAssert(retrivedOfflineAsset!.srcURL == "https://www.test.com.m3u8") + } + +} diff --git a/iOSPlayerSDK.xcodeproj/project.pbxproj b/iOSPlayerSDK.xcodeproj/project.pbxproj index 4337b8a..9ebb454 100644 --- a/iOSPlayerSDK.xcodeproj/project.pbxproj +++ b/iOSPlayerSDK.xcodeproj/project.pbxproj @@ -61,6 +61,8 @@ D9C1FAA22B4E7E2600B27A3B /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = D9C1FAA12B4E7E2600B27A3B /* RealmSwift */; }; D9C1FAA82B4E7EE700B27A3B /* OfflineAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1FAA72B4E7EE700B27A3B /* OfflineAsset.swift */; }; D9C1FAB22B4E915C00B27A3B /* ObjectManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1FAB12B4E915C00B27A3B /* ObjectManager.swift */; }; + D9C1FAB62B4EA4CF00B27A3B /* TPStreamsDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1FAB52B4EA4CF00B27A3B /* TPStreamsDownloadManager.swift */; }; + D9C1FAB82B4EA53200B27A3B /* ObjectManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1FAB72B4EA53200B27A3B /* ObjectManagerTest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -171,6 +173,8 @@ D904B1DD2B3C5BAC00A7E26C /* TPStreamsSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TPStreamsSDK.xctestplan; sourceTree = ""; }; D9C1FAA72B4E7EE700B27A3B /* OfflineAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineAsset.swift; sourceTree = ""; }; D9C1FAB12B4E915C00B27A3B /* ObjectManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectManager.swift; sourceTree = ""; }; + D9C1FAB52B4EA4CF00B27A3B /* TPStreamsDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPStreamsDownloadManager.swift; sourceTree = ""; }; + D9C1FAB72B4EA53200B27A3B /* ObjectManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectManagerTest.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -392,6 +396,7 @@ isa = PBXGroup; children = ( 8EDE99B32A2643B000E43EA9 /* iOSPlayerSDKTests.swift */, + D9C1FAB72B4EA53200B27A3B /* ObjectManagerTest.swift */, ); path = Tests; sourceTree = ""; @@ -401,6 +406,7 @@ children = ( D9C1FAB02B4E910800B27A3B /* Model */, D9C1FAB12B4E915C00B27A3B /* ObjectManager.swift */, + D9C1FAB52B4EA4CF00B27A3B /* TPStreamsDownloadManager.swift */, ); path = Database; sourceTree = ""; @@ -630,6 +636,7 @@ 8E6389E92A278D1D00306FA4 /* ContentKeyDelegate.swift in Sources */, 8E6389DD2A27338F00306FA4 /* AVPlayerBridge.swift in Sources */, 8E6389BC2A2724D000306FA4 /* TPStreamPlayerView.swift in Sources */, + D9C1FAB62B4EA4CF00B27A3B /* TPStreamsDownloadManager.swift in Sources */, 035351A02A2EDAFA001E38F3 /* PlayerSettingsButton.swift in Sources */, 03913C482A850BF9002E7E0C /* ProgressBar.swift in Sources */, 8E6C5CB52A28BD9A003EC948 /* TPStreamsSDK.swift in Sources */, @@ -661,6 +668,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D9C1FAB82B4EA53200B27A3B /* ObjectManagerTest.swift in Sources */, 8EDE99B42A2643B000E43EA9 /* iOSPlayerSDKTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;